Kizuna (絆) means "bond" or "connection" in Japanese. It's a collaborative Texthooker UI page made for Japanese Learners who want to track their progress and immersion alone or with friends while playing japanese games or reading books and visual novels.
It is inspired by the great Renji's Texthooker UI page and aims to provide a familiar interface while adding new features, such as:
Start by creating your own room or joining an existing one with a room ID. Rooms can be password-protected for privacy.
⚠️ Important:
If it's your first time using Kizuna, choose a username and save the code that is displayed once you join. It will be required if you ever need to authenticate again (from a different computer or browser, for example.)
Connect Kizuna to your text source to start tracking your reading.
Check the tab for detailed setup instructions.
Once you join a room, you will see the texthooker page. If the websocket connection worked, the connection icon will be green. For connection methods that do not use a websocket, such as clipboard inserter or extension scripts, the connection icon won't change.
You should then be able to read as usual. Once you finish your reading session, open the Kizuna menu and click Log Reading Session. Type the name of the game or visual novel you were playing, select it from the choices and click Log.
Your reading session is now logged. You will be able to see it in the Room's reading log and in your profile tab. It will also be reflected on all stats views.
// ==UserScript==
// @name TTSU<->Kizuna
// @version 1.0
// @description Stream visible text from TTSU to Kizuna using IntersectionObserver
// @match https://reader.ttsu.app/*
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const SYNC_KEY = 'kizuna_bridge_universal';
// --- HELPER: CLEAN TEXT ---
function getCleanText(el) {
if (!el) return "";
const clone = el.cloneNode(true);
// Remove rt tags (furigana) but keep the base text
clone.querySelectorAll('rt').forEach(rt => rt.remove());
// Remove rp tags (fallback parenthesis)
clone.querySelectorAll('rp').forEach(rp => rp.remove());
return clone.textContent.trim();
}
// --- INJECTOR FOR KIZUNA (RECEIVER) ---
function injectKizunaReceiver() {
const script = document.createElement('script');
script.textContent = `
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'TTSU_TO_KIZUNA') {
window.dispatchEvent(new CustomEvent('kizuna:add-line', {
detail: { text: e.data.text }
}));
}
});
`;
(document.head || document.documentElement).appendChild(script);
}
// --- TTSU READER LOGIC (SENDER) ---
if (window.location.hostname.includes('reader.ttsu.app')) {
// A Set to remember what we have already sent to Kizuna so we don't duplicate
const sentHistory = new Set();
let transmissionBuffer = [];
// A Set to keep track of what is currently on screen according to the browser
const visibleElements = new Set();
// 1. The Observer: The browser tells us what is on screen
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
visibleElements.add(entry.target);
} else {
visibleElements.delete(entry.target);
}
});
}, {
root: null, // viewport
rootMargin: '0px',
threshold: 0.5 // Element must be 50% visible to count (prevents partial lines at edges)
});
// 2. The Scanner: Finds new paragraphs and tells the Observer to watch them
setInterval(() => {
// Target the specific paragraphs based on your structure
const paragraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
paragraphs.forEach(p => {
// If we haven't started watching this specific <p> tag yet, watch it
if (!p.getAttribute('data-bridge-observed')) {
p.setAttribute('data-bridge-observed', 'true');
observer.observe(p);
}
});
}, 1000); // Check for new chapters/content every second
// 3. The Sender: Periodically checks what is visible and sends it
setInterval(() => {
// We select again to ensure we process in DOM ORDER (Top to bottom)
// This ensures text arrives in the correct reading order
const allParagraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
allParagraphs.forEach(p => {
// Logic:
// 1. Is it currently flagged as visible by the Observer?
// 2. Is there valid text?
// 3. Have we sent this specific text string before?
if (visibleElements.has(p)) {
const text = getCleanText(p);
if (text && text.length > 0 && !sentHistory.has(text)) {
sentHistory.add(text);
transmissionBuffer.push(text);
console.log("TTSU Bridge Queued:", text);
}
}
});
// Send batch if data exists
if (transmissionBuffer.length > 0) {
GM_setValue(SYNC_KEY, { lines: [...transmissionBuffer], ts: Date.now() });
transmissionBuffer = [];
}
}, 500); // Check for text to send every 500ms
}
// --- KIZUNA UI LOGIC (RECEIVER) ---
if (window.location.hostname.includes('kizuna-texthooker-ui')) {
injectKizunaReceiver();
GM_addValueChangeListener(SYNC_KEY, (name, old, newVal, remote) => {
if (remote && newVal && newVal.lines) {
newVal.lines.forEach(text => {
window.postMessage({ type: 'TTSU_TO_KIZUNA', text: text }, "*");
});
}
});
}
})();
// ==UserScript==
// @name TTSU<->Kizuna
// @version 1.0
// @description Stream visible text from TTSU to Kizuna using IntersectionObserver
// @match https://reader.ttsu.app/*
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const SYNC_KEY = 'kizuna_bridge_universal';
// --- HELPER: CLEAN TEXT ---
function getCleanText(el) {
if (!el) return "";
const clone = el.cloneNode(true);
// Remove rt tags (furigana) but keep the base text
clone.querySelectorAll('rt').forEach(rt => rt.remove());
// Remove rp tags (fallback parenthesis)
clone.querySelectorAll('rp').forEach(rp => rp.remove());
return clone.textContent.trim();
}
// --- INJECTOR FOR KIZUNA (RECEIVER) ---
function injectKizunaReceiver() {
const script = document.createElement('script');
script.textContent = `
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'TTSU_TO_KIZUNA') {
window.dispatchEvent(new CustomEvent('kizuna:add-line', {
detail: { text: e.data.text }
}));
}
});
`;
(document.head || document.documentElement).appendChild(script);
}
// --- TTSU READER LOGIC (SENDER) ---
if (window.location.hostname.includes('reader.ttsu.app')) {
// A Set to remember what we have already sent to Kizuna so we don't duplicate
const sentHistory = new Set();
let transmissionBuffer = [];
// A Set to keep track of what is currently on screen according to the browser
const visibleElements = new Set();
// 1. The Observer: The browser tells us what is on screen
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
visibleElements.add(entry.target);
} else {
visibleElements.delete(entry.target);
}
});
}, {
root: null, // viewport
rootMargin: '0px',
threshold: 0.5 // Element must be 50% visible to count (prevents partial lines at edges)
});
// 2. The Scanner: Finds new paragraphs and tells the Observer to watch them
setInterval(() => {
// Target the specific paragraphs based on your structure
const paragraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
paragraphs.forEach(p => {
// If we haven't started watching this specific <p> tag yet, watch it
if (!p.getAttribute('data-bridge-observed')) {
p.setAttribute('data-bridge-observed', 'true');
observer.observe(p);
}
});
}, 1000); // Check for new chapters/content every second
// 3. The Sender: Periodically checks what is visible and sends it
setInterval(() => {
// We select again to ensure we process in DOM ORDER (Top to bottom)
// This ensures text arrives in the correct reading order
const allParagraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
allParagraphs.forEach(p => {
// Logic:
// 1. Is it currently flagged as visible by the Observer?
// 2. Is there valid text?
// 3. Have we sent this specific text string before?
if (visibleElements.has(p)) {
const text = getCleanText(p);
if (text && text.length > 0 && !sentHistory.has(text)) {
sentHistory.add(text);
transmissionBuffer.push(text);
console.log("TTSU Bridge Queued:", text);
}
}
});
// Send batch if data exists
if (transmissionBuffer.length > 0) {
GM_setValue(SYNC_KEY, { lines: [...transmissionBuffer], ts: Date.now() });
transmissionBuffer = [];
}
}, 500); // Check for text to send every 500ms
}
// --- KIZUNA UI LOGIC (RECEIVER) ---
if (window.location.hostname.includes('kizuna-texthooker-ui')) {
injectKizunaReceiver();
GM_addValueChangeListener(SYNC_KEY, (name, old, newVal, remote) => {
if (remote && newVal && newVal.lines) {
newVal.lines.forEach(text => {
window.postMessage({ type: 'TTSU_TO_KIZUNA', text: text }, "*");
});
}
});
}
})();
Optional userscripts that add extra functionality to Kizuna.
They require some userscript browser extension, such as Violentmonkey.
Automatically add visual novel screenshots to your Anki cards
// ==UserScript==
// @name Anki Auto Screenshot Capture
// @version 1.0
// @description Automatically capture window screenshots when new Anki cards are created
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_xmlhttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @connect localhost
// ==/UserScript==
(function() {
'use strict';
// Configuration
const ANKI_URL = "http://localhost:8765";
const PICTURE_FIELD = "Picture";
const POLL_INTERVAL = 1000; // Check every 1 second
// Crop settings to remove window chrome (title bar, borders)
const CROP_SETTINGS = {
top: 32, // Title bar height
bottom: 8, // Bottom border
left: 8, // Left border
right: 8 // Right border
};
let currentNoteIds = new Set();
let firstRun = true;
let isCapturing = false;
let captureStream = null; // Keep the stream alive
let captureVideo = null; // Keep the video element
let lastWindowDimensions = null; // Track last captured window size
// Start polling for new Anki cards
startPolling();
async function startPolling() {
console.log('Started polling Anki for new cards...');
while (true) {
await checkForNewCards();
await sleep(POLL_INTERVAL);
}
}
async function checkForNewCards() {
try {
const updatedNoteIds = await getNoteIds();
const newCardIds = difference(updatedNoteIds, currentNoteIds);
if (newCardIds.size > 0 && !firstRun) {
console.log('Detected new card in Anki! Capturing screenshot...');
if (!isCapturing) {
isCapturing = true;
await captureAndAddToAnki();
isCapturing = false;
}
}
firstRun = false;
currentNoteIds = updatedNoteIds;
} catch (error) {
console.error('Error checking for new cards:', error);
}
}
async function getNoteIds() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
return new Set(noteIds);
}
function difference(setA, setB) {
const diff = new Set();
for (const elem of setA) {
if (!setB.has(elem)) {
diff.add(elem);
}
}
return diff;
}
async function captureAndAddToAnki() {
try {
// Small delay to ensure card is fully created
await sleep(500);
// Capture the screenshot
const webpDataUrl = await captureWindowScreenshot();
// Get the latest Anki card
const lastNote = await getLastAnkiCard();
if (!lastNote || !lastNote.noteId) {
showNotification('No Anki cards found for today', 'error');
return;
}
// Check if card already has an image
if (lastNote.fields[PICTURE_FIELD] && lastNote.fields[PICTURE_FIELD].value) {
console.log('Latest card already has an image, skipping');
return;
}
// Convert data URL to base64
const base64Data = webpDataUrl.split(',')[1];
// Generate filename
const filename = `screenshot_${Date.now()}.webp`;
// Store the image in Anki
const storedFilename = await storeMediaFile(filename, base64Data);
// Update the card with the image
await updateNoteWithImage(lastNote.noteId, storedFilename);
showNotification('✓ Screenshot added to Anki card!', 'success');
console.log(`Updated Anki card ${lastNote.noteId} with screenshot`);
} catch (error) {
console.error('Error:', error);
showNotification('Failed: ' + error.message, 'error');
}
}
async function initializeCaptureStream() {
try {
// Request screen capture - user will be prompted ONCE to select window
captureStream = await navigator.mediaDevices.getDisplayMedia({
video: {
mediaSource: 'window'
},
audio: false
});
captureVideo = document.createElement('video');
captureVideo.srcObject = captureStream;
captureVideo.autoplay = true;
await new Promise((resolve) => {
captureVideo.onloadedmetadata = resolve;
});
console.log('Capture stream initialized and ready');
// Show crop adjustment dialog
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
// Store initial window dimensions
lastWindowDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
console.log('Crop settings updated:', CROP_SETTINGS);
// Handle stream ending (e.g., user stops sharing)
captureStream.getTracks()[0].addEventListener('ended', () => {
console.log('Capture stream ended');
captureStream = null;
captureVideo = null;
lastWindowDimensions = null;
});
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function showCropAdjustmentDialog(video) {
return new Promise(async (resolve) => {
// Load saved crop settings from GM storage
let savedSettings = null;
try {
const saved = await GM.getValue('ankiAutoCropSettings', null);
if (saved) {
savedSettings = JSON.parse(saved);
}
} catch (e) {
console.warn('Failed to load saved crop settings:', e);
}
// Capture current frame for preview
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = video.videoWidth;
sourceCanvas.height = video.videoHeight;
const sourceCtx = sourceCanvas.getContext('2d');
sourceCtx.drawImage(video, 0, 0);
// Create preview canvas for cropped result
const previewCanvas = document.createElement('canvas');
const previewCtx = previewCanvas.getContext('2d');
// Create modal overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 999999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
`;
// Create container
const container = document.createElement('div');
container.style.cssText = `
background: #2b2b2b;
border-radius: 12px;
padding: 20px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 15px;
box-sizing: border-box;
overflow: hidden;
`;
// Title
const title = document.createElement('h2');
title.textContent = 'Adjust Screenshot Crop Area';
title.style.cssText = `
margin: 0;
color: white;
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
flex-shrink: 0;
`;
// Preview container with canvas
const previewContainer = document.createElement('div');
previewContainer.style.cssText = `
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
`;
// Style the preview canvas
previewCanvas.style.cssText = `
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border: 1px solid #555;
`;
// Controls container
const controlsContainer = document.createElement('div');
controlsContainer.style.cssText = `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
color: white;
font-family: Arial, sans-serif;
font-size: 14px;
flex-shrink: 0;
`;
// Current crop values - use saved settings if available
let cropValues = savedSettings ? {
left: savedSettings.left,
right: savedSettings.right,
top: savedSettings.top,
bottom: savedSettings.bottom
} : {
left: CROP_SETTINGS.left,
right: CROP_SETTINGS.right,
top: CROP_SETTINGS.top,
bottom: CROP_SETTINGS.bottom
};
// Function to update cropped preview
function updateCroppedPreview() {
// Calculate cropped dimensions
const croppedWidth = sourceCanvas.width - cropValues.left - cropValues.right;
const croppedHeight = sourceCanvas.height - cropValues.top - cropValues.bottom;
// Resize preview canvas to match cropped dimensions
previewCanvas.width = croppedWidth;
previewCanvas.height = croppedHeight;
// Draw the cropped portion from source canvas
previewCtx.drawImage(
sourceCanvas,
cropValues.left, // Source X
cropValues.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
}
// Create slider control
function createSlider(label, initialValue, max, onChange) {
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
`;
const labelContainer = document.createElement('div');
labelContainer.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
`;
const labelElement = document.createElement('label');
labelElement.textContent = label;
labelElement.style.fontWeight = 'bold';
const valueDisplay = document.createElement('span');
valueDisplay.textContent = initialValue + 'px';
valueDisplay.style.cssText = `
color: #4CAF50;
font-family: monospace;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = max;
slider.value = initialValue;
slider.style.cssText = `
width: 100%;
cursor: pointer;
`;
const updateValue = (value) => {
// Clamp value between 0 and max
value = Math.max(0, Math.min(max, value));
slider.value = value;
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
};
slider.addEventListener('input', (e) => {
const value = parseInt(e.target.value);
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
});
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 5px;
justify-content: center;
`;
// Decrement button
const decrementButton = document.createElement('button');
decrementButton.textContent = '−';
decrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
decrementButton.addEventListener('mouseover', () => {
decrementButton.style.background = '#555';
});
decrementButton.addEventListener('mouseout', () => {
decrementButton.style.background = '#444';
});
decrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) - 1);
});
// Increment button
const incrementButton = document.createElement('button');
incrementButton.textContent = '+';
incrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
incrementButton.addEventListener('mouseover', () => {
incrementButton.style.background = '#555';
});
incrementButton.addEventListener('mouseout', () => {
incrementButton.style.background = '#444';
});
incrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) + 1);
});
buttonContainer.appendChild(decrementButton);
buttonContainer.appendChild(incrementButton);
labelContainer.appendChild(labelElement);
labelContainer.appendChild(valueDisplay);
sliderContainer.appendChild(labelContainer);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(buttonContainer);
return sliderContainer;
}
// Create sliders
const leftSlider = createSlider('Left', cropValues.left, Math.floor(video.videoWidth / 2), (value) => {
cropValues.left = value;
});
const rightSlider = createSlider('Right', cropValues.right, Math.floor(video.videoWidth / 2), (value) => {
cropValues.right = value;
});
const topSlider = createSlider('Top', cropValues.top, Math.floor(video.videoHeight / 2), (value) => {
cropValues.top = value;
});
const bottomSlider = createSlider('Bottom', cropValues.bottom, Math.floor(video.videoHeight / 2), (value) => {
cropValues.bottom = value;
});
// Add sliders to controls
controlsContainer.appendChild(leftSlider);
controlsContainer.appendChild(rightSlider);
controlsContainer.appendChild(topSlider);
controlsContainer.appendChild(bottomSlider);
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 10px;
justify-content: center;
flex-shrink: 0;
`;
// Confirm button
const confirmButton = document.createElement('button');
confirmButton.textContent = 'Confirm Crop Settings';
confirmButton.style.cssText = `
padding: 12px 24px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
`;
confirmButton.addEventListener('mouseover', () => {
confirmButton.style.background = '#45a049';
});
confirmButton.addEventListener('mouseout', () => {
confirmButton.style.background = '#4CAF50';
});
confirmButton.addEventListener('click', async () => {
// Save crop settings to GM storage
try {
await GM.setValue('ankiAutoCropSettings', JSON.stringify(cropValues));
console.log('Crop settings saved to GM storage:', cropValues);
} catch (e) {
console.warn('Failed to save crop settings to GM storage:', e);
}
overlay.remove();
resolve(cropValues);
});
// Reset button
const resetButton = document.createElement('button');
resetButton.textContent = 'Reset to Default';
resetButton.style.cssText = `
padding: 12px 24px;
background: #666;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
font-family: Arial, sans-serif;
`;
resetButton.addEventListener('mouseover', () => {
resetButton.style.background = '#555';
});
resetButton.addEventListener('mouseout', () => {
resetButton.style.background = '#666';
});
resetButton.addEventListener('click', () => {
cropValues = { left: 32, right: 8, top: 32, bottom: 8 };
// Update all sliders
leftSlider.querySelector('input').value = cropValues.left;
leftSlider.querySelector('span').textContent = cropValues.left + 'px';
rightSlider.querySelector('input').value = cropValues.right;
rightSlider.querySelector('span').textContent = cropValues.right + 'px';
topSlider.querySelector('input').value = cropValues.top;
topSlider.querySelector('span').textContent = cropValues.top + 'px';
bottomSlider.querySelector('input').value = cropValues.bottom;
bottomSlider.querySelector('span').textContent = cropValues.bottom + 'px';
updateCroppedPreview();
});
// Assemble the dialog
previewContainer.appendChild(previewCanvas);
buttonContainer.appendChild(confirmButton);
buttonContainer.appendChild(resetButton);
container.appendChild(title);
container.appendChild(previewContainer);
container.appendChild(controlsContainer);
container.appendChild(buttonContainer);
overlay.appendChild(container);
document.body.appendChild(overlay);
// Initial preview update
updateCroppedPreview();
});
}
async function captureWindowScreenshot() {
try {
// Initialize stream on first capture
if (!captureStream || !captureVideo) {
await initializeCaptureStream();
}
// Check if window dimensions have changed
const currentDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
if (lastWindowDimensions &&
(lastWindowDimensions.width !== currentDimensions.width ||
lastWindowDimensions.height !== currentDimensions.height)) {
console.log('Window size changed, showing crop adjustment dialog');
console.log('Previous:', lastWindowDimensions, 'Current:', currentDimensions);
// Show crop adjustment dialog again with new dimensions
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
console.log('Crop settings updated:', CROP_SETTINGS);
}
// Store current dimensions for next comparison
lastWindowDimensions = currentDimensions;
// Create canvas and capture current frame
const canvas = document.createElement('canvas');
canvas.width = captureVideo.videoWidth;
canvas.height = captureVideo.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(captureVideo, 0, 0);
// Create a new canvas for the cropped version
const croppedCanvas = document.createElement('canvas');
const croppedWidth = canvas.width - CROP_SETTINGS.left - CROP_SETTINGS.right;
const croppedHeight = canvas.height - CROP_SETTINGS.top - CROP_SETTINGS.bottom;
croppedCanvas.width = croppedWidth;
croppedCanvas.height = croppedHeight;
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the cropped portion (removes window chrome)
croppedCtx.drawImage(
canvas,
CROP_SETTINGS.left, // Source X
CROP_SETTINGS.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
// Convert to WebP with quality setting (0.9 = 90% quality)
const webpDataUrl = croppedCanvas.toDataURL('image/png', 0.9);
return webpDataUrl;
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function getLastAnkiCard() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
if (!noteIds || noteIds.length === 0) {
return null;
}
const lastNoteId = noteIds[noteIds.length - 1];
const notesInfo = await ankiInvoke('notesInfo', { notes: [lastNoteId] });
return notesInfo[0];
}
async function storeMediaFile(filename, base64Data) {
return await ankiInvoke('storeMediaFile', {
filename: filename,
data: base64Data
});
}
async function updateNoteWithImage(noteId, filename) {
const imageHtml = `<img src="${filename}">`;
await ankiInvoke('updateNoteFields', {
note: {
id: noteId,
fields: {
[PICTURE_FIELD]: imageHtml
}
}
});
}
function ankiInvoke(action, params = {}) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
action: action,
version: 6,
params: params
});
GM_xmlhttpRequest({
method: 'POST',
url: ANKI_URL,
headers: {
'Content-Type': 'application/json'
},
data: payload,
onload: function(response) {
try {
const result = JSON.parse(response.responseText);
if (result.error) {
reject(new Error(result.error));
} else {
resolve(result.result);
}
} catch (e) {
reject(new Error('Failed to parse Anki response'));
}
},
onerror: function(error) {
reject(new Error('Failed to connect to Anki'));
}
});
});
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
const colors = {
success: '#4CAF50',
error: '#f44336',
warning: '#ff9800',
info: '#2196F3'
};
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors[type]};
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
z-index: 10001;
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: 500;
max-width: 300px;
animation: slideIn 0.3s ease-out;
`;
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
if (!document.querySelector('style[data-anki-notif]')) {
style.setAttribute('data-anki-notif', 'true');
document.head.appendChild(style);
}
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.3s ease-out';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
console.log('Anki Auto Screenshot Capture loaded. Monitoring for new cards...');
})();
// ==UserScript==
// @name Anki Auto Screenshot Capture
// @version 1.0
// @description Automatically capture window screenshots when new Anki cards are created
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_xmlhttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @connect localhost
// ==/UserScript==
(function() {
'use strict';
// Configuration
const ANKI_URL = "http://localhost:8765";
const PICTURE_FIELD = "Picture";
const POLL_INTERVAL = 1000; // Check every 1 second
// Crop settings to remove window chrome (title bar, borders)
const CROP_SETTINGS = {
top: 32, // Title bar height
bottom: 8, // Bottom border
left: 8, // Left border
right: 8 // Right border
};
let currentNoteIds = new Set();
let firstRun = true;
let isCapturing = false;
let captureStream = null; // Keep the stream alive
let captureVideo = null; // Keep the video element
let lastWindowDimensions = null; // Track last captured window size
// Start polling for new Anki cards
startPolling();
async function startPolling() {
console.log('Started polling Anki for new cards...');
while (true) {
await checkForNewCards();
await sleep(POLL_INTERVAL);
}
}
async function checkForNewCards() {
try {
const updatedNoteIds = await getNoteIds();
const newCardIds = difference(updatedNoteIds, currentNoteIds);
if (newCardIds.size > 0 && !firstRun) {
console.log('Detected new card in Anki! Capturing screenshot...');
if (!isCapturing) {
isCapturing = true;
await captureAndAddToAnki();
isCapturing = false;
}
}
firstRun = false;
currentNoteIds = updatedNoteIds;
} catch (error) {
console.error('Error checking for new cards:', error);
}
}
async function getNoteIds() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
return new Set(noteIds);
}
function difference(setA, setB) {
const diff = new Set();
for (const elem of setA) {
if (!setB.has(elem)) {
diff.add(elem);
}
}
return diff;
}
async function captureAndAddToAnki() {
try {
// Small delay to ensure card is fully created
await sleep(500);
// Capture the screenshot
const webpDataUrl = await captureWindowScreenshot();
// Get the latest Anki card
const lastNote = await getLastAnkiCard();
if (!lastNote || !lastNote.noteId) {
showNotification('No Anki cards found for today', 'error');
return;
}
// Check if card already has an image
if (lastNote.fields[PICTURE_FIELD] && lastNote.fields[PICTURE_FIELD].value) {
console.log('Latest card already has an image, skipping');
return;
}
// Convert data URL to base64
const base64Data = webpDataUrl.split(',')[1];
// Generate filename
const filename = `screenshot_${Date.now()}.webp`;
// Store the image in Anki
const storedFilename = await storeMediaFile(filename, base64Data);
// Update the card with the image
await updateNoteWithImage(lastNote.noteId, storedFilename);
showNotification('✓ Screenshot added to Anki card!', 'success');
console.log(`Updated Anki card ${lastNote.noteId} with screenshot`);
} catch (error) {
console.error('Error:', error);
showNotification('Failed: ' + error.message, 'error');
}
}
async function initializeCaptureStream() {
try {
// Request screen capture - user will be prompted ONCE to select window
captureStream = await navigator.mediaDevices.getDisplayMedia({
video: {
mediaSource: 'window'
},
audio: false
});
captureVideo = document.createElement('video');
captureVideo.srcObject = captureStream;
captureVideo.autoplay = true;
await new Promise((resolve) => {
captureVideo.onloadedmetadata = resolve;
});
console.log('Capture stream initialized and ready');
// Show crop adjustment dialog
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
// Store initial window dimensions
lastWindowDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
console.log('Crop settings updated:', CROP_SETTINGS);
// Handle stream ending (e.g., user stops sharing)
captureStream.getTracks()[0].addEventListener('ended', () => {
console.log('Capture stream ended');
captureStream = null;
captureVideo = null;
lastWindowDimensions = null;
});
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function showCropAdjustmentDialog(video) {
return new Promise(async (resolve) => {
// Load saved crop settings from GM storage
let savedSettings = null;
try {
const saved = await GM.getValue('ankiAutoCropSettings', null);
if (saved) {
savedSettings = JSON.parse(saved);
}
} catch (e) {
console.warn('Failed to load saved crop settings:', e);
}
// Capture current frame for preview
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = video.videoWidth;
sourceCanvas.height = video.videoHeight;
const sourceCtx = sourceCanvas.getContext('2d');
sourceCtx.drawImage(video, 0, 0);
// Create preview canvas for cropped result
const previewCanvas = document.createElement('canvas');
const previewCtx = previewCanvas.getContext('2d');
// Create modal overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 999999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
`;
// Create container
const container = document.createElement('div');
container.style.cssText = `
background: #2b2b2b;
border-radius: 12px;
padding: 20px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 15px;
box-sizing: border-box;
overflow: hidden;
`;
// Title
const title = document.createElement('h2');
title.textContent = 'Adjust Screenshot Crop Area';
title.style.cssText = `
margin: 0;
color: white;
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
flex-shrink: 0;
`;
// Preview container with canvas
const previewContainer = document.createElement('div');
previewContainer.style.cssText = `
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
`;
// Style the preview canvas
previewCanvas.style.cssText = `
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border: 1px solid #555;
`;
// Controls container
const controlsContainer = document.createElement('div');
controlsContainer.style.cssText = `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
color: white;
font-family: Arial, sans-serif;
font-size: 14px;
flex-shrink: 0;
`;
// Current crop values - use saved settings if available
let cropValues = savedSettings ? {
left: savedSettings.left,
right: savedSettings.right,
top: savedSettings.top,
bottom: savedSettings.bottom
} : {
left: CROP_SETTINGS.left,
right: CROP_SETTINGS.right,
top: CROP_SETTINGS.top,
bottom: CROP_SETTINGS.bottom
};
// Function to update cropped preview
function updateCroppedPreview() {
// Calculate cropped dimensions
const croppedWidth = sourceCanvas.width - cropValues.left - cropValues.right;
const croppedHeight = sourceCanvas.height - cropValues.top - cropValues.bottom;
// Resize preview canvas to match cropped dimensions
previewCanvas.width = croppedWidth;
previewCanvas.height = croppedHeight;
// Draw the cropped portion from source canvas
previewCtx.drawImage(
sourceCanvas,
cropValues.left, // Source X
cropValues.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
}
// Create slider control
function createSlider(label, initialValue, max, onChange) {
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
`;
const labelContainer = document.createElement('div');
labelContainer.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
`;
const labelElement = document.createElement('label');
labelElement.textContent = label;
labelElement.style.fontWeight = 'bold';
const valueDisplay = document.createElement('span');
valueDisplay.textContent = initialValue + 'px';
valueDisplay.style.cssText = `
color: #4CAF50;
font-family: monospace;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = max;
slider.value = initialValue;
slider.style.cssText = `
width: 100%;
cursor: pointer;
`;
const updateValue = (value) => {
// Clamp value between 0 and max
value = Math.max(0, Math.min(max, value));
slider.value = value;
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
};
slider.addEventListener('input', (e) => {
const value = parseInt(e.target.value);
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
});
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 5px;
justify-content: center;
`;
// Decrement button
const decrementButton = document.createElement('button');
decrementButton.textContent = '−';
decrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
decrementButton.addEventListener('mouseover', () => {
decrementButton.style.background = '#555';
});
decrementButton.addEventListener('mouseout', () => {
decrementButton.style.background = '#444';
});
decrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) - 1);
});
// Increment button
const incrementButton = document.createElement('button');
incrementButton.textContent = '+';
incrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
incrementButton.addEventListener('mouseover', () => {
incrementButton.style.background = '#555';
});
incrementButton.addEventListener('mouseout', () => {
incrementButton.style.background = '#444';
});
incrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) + 1);
});
buttonContainer.appendChild(decrementButton);
buttonContainer.appendChild(incrementButton);
labelContainer.appendChild(labelElement);
labelContainer.appendChild(valueDisplay);
sliderContainer.appendChild(labelContainer);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(buttonContainer);
return sliderContainer;
}
// Create sliders
const leftSlider = createSlider('Left', cropValues.left, Math.floor(video.videoWidth / 2), (value) => {
cropValues.left = value;
});
const rightSlider = createSlider('Right', cropValues.right, Math.floor(video.videoWidth / 2), (value) => {
cropValues.right = value;
});
const topSlider = createSlider('Top', cropValues.top, Math.floor(video.videoHeight / 2), (value) => {
cropValues.top = value;
});
const bottomSlider = createSlider('Bottom', cropValues.bottom, Math.floor(video.videoHeight / 2), (value) => {
cropValues.bottom = value;
});
// Add sliders to controls
controlsContainer.appendChild(leftSlider);
controlsContainer.appendChild(rightSlider);
controlsContainer.appendChild(topSlider);
controlsContainer.appendChild(bottomSlider);
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 10px;
justify-content: center;
flex-shrink: 0;
`;
// Confirm button
const confirmButton = document.createElement('button');
confirmButton.textContent = 'Confirm Crop Settings';
confirmButton.style.cssText = `
padding: 12px 24px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
`;
confirmButton.addEventListener('mouseover', () => {
confirmButton.style.background = '#45a049';
});
confirmButton.addEventListener('mouseout', () => {
confirmButton.style.background = '#4CAF50';
});
confirmButton.addEventListener('click', async () => {
// Save crop settings to GM storage
try {
await GM.setValue('ankiAutoCropSettings', JSON.stringify(cropValues));
console.log('Crop settings saved to GM storage:', cropValues);
} catch (e) {
console.warn('Failed to save crop settings to GM storage:', e);
}
overlay.remove();
resolve(cropValues);
});
// Reset button
const resetButton = document.createElement('button');
resetButton.textContent = 'Reset to Default';
resetButton.style.cssText = `
padding: 12px 24px;
background: #666;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
font-family: Arial, sans-serif;
`;
resetButton.addEventListener('mouseover', () => {
resetButton.style.background = '#555';
});
resetButton.addEventListener('mouseout', () => {
resetButton.style.background = '#666';
});
resetButton.addEventListener('click', () => {
cropValues = { left: 32, right: 8, top: 32, bottom: 8 };
// Update all sliders
leftSlider.querySelector('input').value = cropValues.left;
leftSlider.querySelector('span').textContent = cropValues.left + 'px';
rightSlider.querySelector('input').value = cropValues.right;
rightSlider.querySelector('span').textContent = cropValues.right + 'px';
topSlider.querySelector('input').value = cropValues.top;
topSlider.querySelector('span').textContent = cropValues.top + 'px';
bottomSlider.querySelector('input').value = cropValues.bottom;
bottomSlider.querySelector('span').textContent = cropValues.bottom + 'px';
updateCroppedPreview();
});
// Assemble the dialog
previewContainer.appendChild(previewCanvas);
buttonContainer.appendChild(confirmButton);
buttonContainer.appendChild(resetButton);
container.appendChild(title);
container.appendChild(previewContainer);
container.appendChild(controlsContainer);
container.appendChild(buttonContainer);
overlay.appendChild(container);
document.body.appendChild(overlay);
// Initial preview update
updateCroppedPreview();
});
}
async function captureWindowScreenshot() {
try {
// Initialize stream on first capture
if (!captureStream || !captureVideo) {
await initializeCaptureStream();
}
// Check if window dimensions have changed
const currentDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
if (lastWindowDimensions &&
(lastWindowDimensions.width !== currentDimensions.width ||
lastWindowDimensions.height !== currentDimensions.height)) {
console.log('Window size changed, showing crop adjustment dialog');
console.log('Previous:', lastWindowDimensions, 'Current:', currentDimensions);
// Show crop adjustment dialog again with new dimensions
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
console.log('Crop settings updated:', CROP_SETTINGS);
}
// Store current dimensions for next comparison
lastWindowDimensions = currentDimensions;
// Create canvas and capture current frame
const canvas = document.createElement('canvas');
canvas.width = captureVideo.videoWidth;
canvas.height = captureVideo.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(captureVideo, 0, 0);
// Create a new canvas for the cropped version
const croppedCanvas = document.createElement('canvas');
const croppedWidth = canvas.width - CROP_SETTINGS.left - CROP_SETTINGS.right;
const croppedHeight = canvas.height - CROP_SETTINGS.top - CROP_SETTINGS.bottom;
croppedCanvas.width = croppedWidth;
croppedCanvas.height = croppedHeight;
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the cropped portion (removes window chrome)
croppedCtx.drawImage(
canvas,
CROP_SETTINGS.left, // Source X
CROP_SETTINGS.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
// Convert to WebP with quality setting (0.9 = 90% quality)
const webpDataUrl = croppedCanvas.toDataURL('image/png', 0.9);
return webpDataUrl;
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function getLastAnkiCard() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
if (!noteIds || noteIds.length === 0) {
return null;
}
const lastNoteId = noteIds[noteIds.length - 1];
const notesInfo = await ankiInvoke('notesInfo', { notes: [lastNoteId] });
return notesInfo[0];
}
async function storeMediaFile(filename, base64Data) {
return await ankiInvoke('storeMediaFile', {
filename: filename,
data: base64Data
});
}
async function updateNoteWithImage(noteId, filename) {
const imageHtml = `<img src="${filename}">`;
await ankiInvoke('updateNoteFields', {
note: {
id: noteId,
fields: {
[PICTURE_FIELD]: imageHtml
}
}
});
}
function ankiInvoke(action, params = {}) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
action: action,
version: 6,
params: params
});
GM_xmlhttpRequest({
method: 'POST',
url: ANKI_URL,
headers: {
'Content-Type': 'application/json'
},
data: payload,
onload: function(response) {
try {
const result = JSON.parse(response.responseText);
if (result.error) {
reject(new Error(result.error));
} else {
resolve(result.result);
}
} catch (e) {
reject(new Error('Failed to parse Anki response'));
}
},
onerror: function(error) {
reject(new Error('Failed to connect to Anki'));
}
});
});
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
const colors = {
success: '#4CAF50',
error: '#f44336',
warning: '#ff9800',
info: '#2196F3'
};
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors[type]};
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
z-index: 10001;
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: 500;
max-width: 300px;
animation: slideIn 0.3s ease-out;
`;
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
if (!document.querySelector('style[data-anki-notif]')) {
style.setAttribute('data-anki-notif', 'true');
document.head.appendChild(style);
}
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.3s ease-out';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
console.log('Anki Auto Screenshot Capture loaded. Monitoring for new cards...');
})();
Runs Meiki OCR in the browser and sends the recognized text to Kizuna automatically
// ==UserScript==
// @name Meiki OCR
// @namespace meiki-ocr
// @version 1.0.0
// @description Screen capture OCR using meiki.text.detect.v0.1 + meiki.text.rec.v0 ONNX models. Pipeline ported from the meikiocr Python library.
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect huggingface.co
// @connect hf.co
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/ort.min.js
// ==/UserScript==
/**
* Local Japanese OCR for the Kizuna texthooker.
*
* Captures a screen region with getDisplayMedia, runs a two-stage ONNX pipeline
* in the browser, and dispatches recognized lines to the page as a
* `kizuna:add-line` CustomEvent. Nothing leaves the machine.
*
* detection meiki.text.detect.v0.1.320x192 — finds text line boxes
* recognition meiki.text.rec.v0.960x32 — reads characters in each box
*
* Preprocessing and postprocessing follow the meikiocr Python library:
* https://github.com/rtr46/meikiocr
*
* Auto mode watches the region on a sampling grid and only runs OCR once the
* picture has settled, so a text box mid-animation isn't read half-drawn.
*
* Limitations: horizontal text only. Detection is capped at 64 boxes and
* recognition at 48 characters per line, which is ample for game text but not
* for dense pages. Trained on rendered game text, so handwriting and photos
* will disappoint.
*/
(async function () {
'use strict';
// === CONFIG ===============================================================
// Detection: v0.1.320x192 — newer architecture (MobileNet v4 small + D-FINE),
// RGB input, stretch-resize (no letterbox), per-axis ratio unmap. ~30ms
// native CPU per the model card; expect ~2-4x in WASM.
const DETECT_MODEL_URL =
'https://huggingface.co/rtr46/meiki.text.detect.v0/resolve/main/meiki.text.detect.v0.1.320x192.onnx';
const DETECT_W = 320;
const DETECT_H = 192;
const DET_CONF_THRESHOLD = 0.4;
// Overlap above which two detected boxes are treated as the same text line.
const DET_OVERLAP_THRESHOLD = 0.6;
// Recognition: meiki.text.rec.v0 horizontal model. Input is 960x32 RGB,
// aspect-preserving fit with bottom+right zero padding.
const RECOG_MODEL_URL =
'https://huggingface.co/rtr46/meiki.txt.recognition.v0/resolve/main/meiki.text.rec.v0.960x32.onnx';
const RECOG_W = 960;
const RECOG_H = 32;
const REC_CONF_THRESHOLD = 0.1;
// Postprocessing knobs from the meikiocr library.
const PUNCT_CONF_FACTOR = 0.2;
const X_OVERLAP_THRESHOLD = 0.3;
const EPSILON = 1e-6;
// Repeat suppression. A line is dropped if it's too close to the previous
// dispatched line — this absorbs recognition wobble (a bracket read as a
// pipe, a clipped leading 「, a dropped character) that would otherwise
// arrive as a near-identical duplicate.
//
// All three conditions must hold before a *near* match is suppressed; an
// exact match is always suppressed regardless of length. Raising MAX_EDITS
// catches more wobble but starts eating real lines, so widen MIN_LENGTH at
// the same time if you do.
const DEDUP_MAX_EDITS = 2; // absolute edit-distance ceiling
const DEDUP_MAX_RATIO = 0.1; // and no more than this fraction of the line
const DEDUP_MIN_LENGTH = 10; // shorter lines use exact matching only
// Drop any line that contains zero kana/kanji. This is what meikipop's
// provider does — it kills English UI, watermarks, romaji subtitles, etc.
// Hiragana U+3040–309F, Katakana U+30A0–30FF, CJK Unified U+4E00–9FAF.
const JAPANESE_REGEX = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
// Swapped-pair fixes (pairs the model is known to emit reversed).
const SWAPPED_PAIRS = {
'儡傀': '傀儡',
'談冗': '冗談',
'汰淘': '淘汰',
'沱滂': '滂沱',
'攣痙': '痙攣',
'酊酩': '酩酊',
'麭麺': '麺麭',
'哭慟': '慟哭',
};
// Change detection.
const SAMPLE_COLS = 48;
const SAMPLE_ROWS = 24;
const CHANGE_RATIO = 0.03;
const PIXEL_THRESHOLD = 18;
const POLL_MS = 80;
const STABLE_TICKS = 5;
const FORCE_OCR_INTERVAL_MS = 4000;
// === LOGGER ===============================================================
function log(...args) { console.log('[MeikiOCR]', ...args); }
// === UI ===================================================================
const ui = createUI();
// === MODEL LOADING ========================================================
let detectSession = null;
let recogSession = null;
let modelsReady = false;
async function loadModels() {
ui.setStatus('Loading models...');
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/';
const [detectBuf, recogBuf] = await Promise.all([
fetchModelCached('detect_v01_320x192', DETECT_MODEL_URL),
fetchModelCached('recog_v0_960x32', RECOG_MODEL_URL),
]);
ui.setStatus('Creating ONNX sessions...');
detectSession = await ort.InferenceSession.create(detectBuf, { executionProviders: ['wasm'] });
recogSession = await ort.InferenceSession.create(recogBuf, { executionProviders: ['wasm'] });
modelsReady = true;
ui.setStatus('Ready');
ui.setReady(true);
log('Models loaded.');
}
// Cache key is keyed to the model identity, so changing model URLs above
// implicitly invalidates the old cache rather than serving stale weights.
async function fetchModelCached(key, url) {
const cacheKey = `meiki_model_${key}`;
ui.setStatus(`Checking cache for ${key}...`);
try {
const cached = GM_getValue(cacheKey, null);
if (cached) {
ui.setStatus(`Loading ${key} from cache...`);
return base64ToArrayBuffer(cached);
}
} catch (e) { /* cache miss */ }
ui.setStatus(`Downloading ${key} (first run only)...`);
const buf = await fetchArrayBuffer(url, (pct) => {
ui.setStatus(`Downloading ${key}... ${pct}%`);
});
try { GM_setValue(cacheKey, arrayBufferToBase64(buf)); }
catch (e) { console.warn('[MeikiOCR] Could not cache model:', e); }
return buf;
}
function fetchArrayBuffer(url, onProgress) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
onprogress: (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
},
onload: (r) => {
if (r.status >= 200 && r.status < 300) resolve(r.response);
else reject(new Error(`HTTP ${r.status} for ${url}`));
},
onerror: reject,
});
});
}
// === CAPTURE STREAM =======================================================
let captureStream = null;
let captureVideo = null;
let scanRegion = null;
let captureBusy = false;
async function initStream() {
captureStream = await navigator.mediaDevices.getDisplayMedia({
video: { mediaSource: 'window' },
audio: false,
});
captureVideo = document.createElement('video');
captureVideo.srcObject = captureStream;
captureVideo.autoplay = true;
captureVideo.muted = true;
captureVideo.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;';
document.body.appendChild(captureVideo);
await new Promise((resolve) => { captureVideo.onloadedmetadata = resolve; });
log(`Stream ready: ${captureVideo.videoWidth}x${captureVideo.videoHeight}`);
captureStream.getTracks()[0].addEventListener('ended', () => {
log('Stream ended');
captureVideo?.remove();
captureStream = null;
captureVideo = null;
if (autoController.running) autoController.stop();
});
}
function grabFrame() {
const canvas = document.createElement('canvas');
canvas.width = captureVideo.videoWidth;
canvas.height = captureVideo.videoHeight;
canvas.getContext('2d').drawImage(captureVideo, 0, 0);
return canvas;
}
function grabRegionCanvas() {
if (!scanRegion) return null;
const { x, y, w, h } = scanRegion;
const full = grabFrame();
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(full, x, y, w, h, 0, 0, w, h);
return c;
}
// === CHANGE DETECTION =====================================================
function sampleCanvas(canvas) {
const W = canvas.width, H = canvas.height;
const data = canvas.getContext('2d').getImageData(0, 0, W, H).data;
const out = new Float32Array(SAMPLE_COLS * SAMPLE_ROWS);
for (let r = 0; r < SAMPLE_ROWS; r++) {
for (let c = 0; c < SAMPLE_COLS; c++) {
const px = Math.floor((c + 0.5) * W / SAMPLE_COLS);
const py = Math.floor((r + 0.5) * H / SAMPLE_ROWS);
const i = (py * W + px) * 4;
out[r * SAMPLE_COLS + c] = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
}
}
return out;
}
function diffSamples(a, b) {
let changed = 0;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > PIXEL_THRESHOLD) changed++;
}
return changed / a.length;
}
// === DETECTION ============================================================
//
// Mirrors meiki.text.detect.v0.1.320x192's official inference script:
// - cv2.resize to 320x192 with INTER_LINEAR (we use canvas drawImage which
// is bilinear by default)
// - per-axis ratios — no letterbox/aspect preservation
// - normalize /255, RGB, NCHW
// - second "orig_target_sizes" input = [[320, 192]] int64 (model unmaps
// coordinates back into 320x192 space internally; we then apply per-axis
// ratios to get back to source coords)
// Overlap of two boxes as a fraction of the smaller one's area.
function boxOverlapRatio(a, b) {
const ix = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0]));
const iy = Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1]));
const inter = ix * iy;
if (inter <= 0) return 0;
const areaA = (a[2] - a[0]) * (a[3] - a[1]);
const areaB = (b[2] - b[0]) * (b[3] - b[1]);
return inter / (Math.min(areaA, areaB) + EPSILON);
}
async function runDetection(img) {
const origW = img.width;
const origH = img.height;
const offscreen = new OffscreenCanvas(DETECT_W, DETECT_H);
const ctx = offscreen.getContext('2d');
// Stretch — no aspect preservation. The model was trained this way.
ctx.drawImage(img, 0, 0, origW, origH, 0, 0, DETECT_W, DETECT_H);
const imageData = ctx.getImageData(0, 0, DETECT_W, DETECT_H);
const pixels = imageData.data;
// RGB, NCHW, /255.
const chSize = DETECT_W * DETECT_H;
const tensor = new Float32Array(3 * chSize);
for (let i = 0; i < chSize; i++) {
tensor[0 * chSize + i] = pixels[i * 4 + 0] / 255.0;
tensor[1 * chSize + i] = pixels[i * 4 + 1] / 255.0;
tensor[2 * chSize + i] = pixels[i * 4 + 2] / 255.0;
}
const imageTensor = new ort.Tensor('float32', tensor, [1, 3, DETECT_H, DETECT_W]);
const sizesTensor = new ort.Tensor(
'int64',
BigInt64Array.from([BigInt(DETECT_W), BigInt(DETECT_H)]),
[1, 2],
);
const inputNames = detectSession.inputNames;
const feeds = {
[inputNames[0]]: imageTensor,
[inputNames[1]]: sizesTensor,
};
const results = await detectSession.run(feeds);
// Model returns three outputs (labels, boxes, scores). We don't care about
// labels for detection. Order in outputNames isn't guaranteed across model
// revisions, so look them up by shape rather than position: boxes is the
// 3-D tensor (batch, N, 4); scores is 2-D (batch, N).
let boxesOut = null;
let scoresOut = null;
for (const name of detectSession.outputNames) {
const t = results[name];
if (t.dims.length === 3 && t.dims[2] === 4) boxesOut = t;
else if (t.dims.length === 2) scoresOut = t;
}
if (!boxesOut || !scoresOut) {
throw new Error('Unexpected detection output shapes: ' +
detectSession.outputNames.map(n => `${n}=${results[n].dims.join('x')}`).join(', '));
}
const rawBoxes = boxesOut.data;
const rawScores = scoresOut.data;
const numBoxes = rawScores.length;
const ratioW = DETECT_W / origW;
const ratioH = DETECT_H / origH;
const scored = [];
for (let i = 0; i < numBoxes; i++) {
const score = rawScores[i];
if (score < DET_CONF_THRESHOLD) continue;
// Coords are in 320x192 space; divide by per-axis ratio to get back to
// source coords. This is what the official inference script does.
const x1 = Math.max(0, Math.min(origW, Math.round(rawBoxes[i * 4 + 0] / ratioW)));
const y1 = Math.max(0, Math.min(origH, Math.round(rawBoxes[i * 4 + 1] / ratioH)));
const x2 = Math.max(0, Math.min(origW, Math.round(rawBoxes[i * 4 + 2] / ratioW)));
const y2 = Math.max(0, Math.min(origH, Math.round(rawBoxes[i * 4 + 3] / ratioH)));
if (x2 <= x1 || y2 <= y1) continue;
scored.push({ box: [x1, y1, x2, y2], score });
}
// Suppress overlapping detections. D-FINE is a DETR-style detector with no
// NMS in its head, so several queries can lock onto the same text line and
// the official inference script's confidence threshold alone won't separate
// them. Two boxes on one line means that line gets recognized twice and,
// because dispatch() joins lines with '', the sentence comes back doubled
// with no visible seam.
//
// Denominator is min(areaA, areaB) rather than the union, so a box wholly
// contained in a larger one is caught too — union-IoU would score that pair
// low and let it through. Stacked text lines in a VN box overlap very
// little vertically, so this doesn't merge real neighbors.
scored.sort((a, b) => b.score - a.score);
const kept = [];
for (const cand of scored) {
let duplicate = false;
for (const k of kept) {
if (boxOverlapRatio(cand.box, k.box) > DET_OVERLAP_THRESHOLD) { duplicate = true; break; }
}
if (duplicate) {
log(` suppressed overlapping box ${cand.box.join(',')} (conf ${cand.score.toFixed(2)})`);
continue;
}
kept.push(cand);
}
const boxes = kept.map(k => k.box);
// Sort top-to-bottom so reading order is natural for horizontal text.
boxes.sort((a, b) => a[1] - b[1]);
return { boxes, origW, origH };
}
// === RECOGNITION ==========================================================
//
// For each detected line:
// 1. Crop from the region canvas using the source-coord bbox.
// 2. Scale so height = 32, preserving aspect ratio. If the resulting width
// would exceed 960, scale BOTH dimensions down so width fits — height
// ends up < 32 in this case. Track both effective_w and effective_h.
// 3. Pad bottom and right with zeros to 960x32.
// 4. Run model. Output is (labels, boxes, scores), where boxes are in the
// 960x32 recognition space.
// 5. Discard candidates that fall in the padding region, map coords back
// into the crop, then translate by the crop origin to get global coords.
// 6. Apply punct confidence factor, NMS by x-overlap (symmetric denominator),
// sort left-to-right, then apply the swapped-pair fix.
function isPunct(ch) {
// Unicode category "P*" covers ASCII punctuation, fullwidth punctuation,
// brackets, quotes, etc. The Python `unicodedata.category()` reference
// uses this check; in JS we approximate via Unicode property escape.
return /\p{P}/u.test(ch);
}
function fixSwappedPairs(text, chars) {
for (const [wrong, correct] of Object.entries(SWAPPED_PAIRS)) {
let idx = text.indexOf(wrong);
while (idx !== -1 && idx + 1 < chars.length) {
text = text.slice(0, idx) + correct + text.slice(idx + 2);
// Swap the char entries so bboxes line up with the corrected order.
const tmp = chars[idx].char;
chars[idx].char = chars[idx + 1].char;
chars[idx + 1].char = tmp;
idx = text.indexOf(wrong, idx + 2);
}
}
return { text, chars };
}
async function recognizeBox(regionImg, box) {
const [x1, y1, x2, y2] = box;
const cropW = Math.max(1, x2 - x1);
const cropH = Math.max(1, y2 - y1);
// Resize-with-aspect into the 960x32 frame.
let scale = RECOG_H / cropH;
let newW = Math.round(cropW * scale);
let newH = RECOG_H;
if (newW > RECOG_W) {
const sx = RECOG_W / newW;
newW = RECOG_W;
newH = Math.round(newH * sx); // < 32
}
const effectiveW = newW;
const effectiveH = newH;
// Crop then draw scaled into the top-left of a 960x32 canvas. The rest
// of the canvas stays black (the canvas is freshly allocated and JS
// initializes pixels to transparent black; we explicitly fill to be sure).
const recogCanvas = new OffscreenCanvas(RECOG_W, RECOG_H);
const recogCtx = recogCanvas.getContext('2d');
recogCtx.fillStyle = '#000';
recogCtx.fillRect(0, 0, RECOG_W, RECOG_H);
recogCtx.drawImage(regionImg, x1, y1, cropW, cropH, 0, 0, newW, newH);
const imageData = recogCtx.getImageData(0, 0, RECOG_W, RECOG_H);
const pixels = imageData.data;
const chSize = RECOG_W * RECOG_H;
const tensor = new Float32Array(3 * chSize);
for (let i = 0; i < chSize; i++) {
tensor[0 * chSize + i] = pixels[i * 4 + 0] / 255.0;
tensor[1 * chSize + i] = pixels[i * 4 + 1] / 255.0;
tensor[2 * chSize + i] = pixels[i * 4 + 2] / 255.0;
}
const imageTensor = new ort.Tensor('float32', tensor, [1, 3, RECOG_H, RECOG_W]);
const sizesTensor = new ort.Tensor(
'int64',
BigInt64Array.from([BigInt(RECOG_W), BigInt(RECOG_H)]),
[1, 2],
);
const inputNames = recogSession.inputNames;
const feeds = {
[inputNames[0]]: imageTensor,
[inputNames[1]]: sizesTensor,
};
const outputs = await recogSession.run(feeds);
const labels = outputs['char_codes'].data;
const rawBx = outputs['boxes'].data;
const scores = outputs['scores'].data;
const numDet = scores.length;
const candidates = [];
for (let i = 0; i < numDet; i++) {
const score = scores[i];
if (score < REC_CONF_THRESHOLD) continue;
let rx1 = rawBx[i * 4 + 0];
let ry1 = rawBx[i * 4 + 1];
let rx2 = rawBx[i * 4 + 2];
let ry2 = rawBx[i * 4 + 3];
// Drop candidates whose left edge is in the right padding region.
if (rx1 >= effectiveW) continue;
// Same for top edge — only matters when effectiveH < 32.
if (ry1 >= effectiveH) continue;
rx1 = Math.min(rx1, effectiveW);
rx2 = Math.min(rx2, effectiveW);
ry1 = Math.min(ry1, effectiveH);
ry2 = Math.min(ry2, effectiveH);
// Map back from recognition space -> crop space, then translate to
// region-coords (which is the global coord space we report to callers).
const cx1 = (rx1 / effectiveW) * cropW;
const cx2 = (rx2 / effectiveW) * cropW;
const cy1 = (ry1 / effectiveH) * cropH;
const cy2 = (ry2 / effectiveH) * cropH;
const gx1 = Math.round(x1 + cx1);
const gy1 = Math.round(y1 + cy1);
const gx2 = Math.round(x1 + cx2);
const gy2 = Math.round(y1 + cy2);
// Validate before decoding. The recognition head emits raw Unicode
// codepoints, but a low-confidence prediction can come back as a value
// that isn't a decodable scalar — out of range, or a lone surrogate,
// which String.fromCodePoint turns into an unpaired surrogate that
// renders as U+FFFD. Those were showing up in place of 「 and 」. Drop
// them and log the raw value so the cause is visible.
const raw = typeof labels[i] === 'bigint' ? Number(labels[i]) : labels[i];
if (!Number.isInteger(raw) || raw < 0x20 || raw > 0x10FFFF ||
(raw >= 0xD800 && raw <= 0xDFFF)) {
log(` dropped undecodable codepoint ${raw} (conf ${score.toFixed(2)})`);
continue;
}
const char = String.fromCodePoint(raw);
if (char === '\uFFFD') {
log(` dropped replacement char from codepoint ${raw} (conf ${score.toFixed(2)})`);
continue;
}
// Punctuation gets its confidence multiplied down so overlapping real
// characters win the NMS tiebreak. We mutate conf BEFORE sorting.
const adjConf = isPunct(char) ? score * PUNCT_CONF_FACTOR : score;
candidates.push({
char,
bbox: [gx1, gy1, gx2, gy2],
conf: adjConf,
// x-interval in crop space is what we NMS over (avoids x1 ties from
// adjacent boxes with different crop origins — though that's moot for
// a single-line single-box case, this keeps the semantics local).
interval: [cx1, cx2],
});
}
// Greedy NMS by confidence with symmetric overlap (min of the two widths
// as denominator). This is the form the library uses, not the form in
// the official inference script.
candidates.sort((a, b) => b.conf - a.conf);
const accepted = [];
for (const cand of candidates) {
const [i1c, i2c] = cand.interval;
const lenC = i2c - i1c + EPSILON;
let overlap = false;
for (const acc of accepted) {
const [i1a, i2a] = acc.interval;
if (i1c >= i2a || i1a >= i2c) continue;
const interLen = Math.max(0, Math.min(i2c, i2a) - Math.max(i1c, i1a));
const lenA = i2a - i1a + EPSILON;
const denom = Math.min(lenC, lenA);
if (interLen / denom > X_OVERLAP_THRESHOLD) { overlap = true; break; }
}
if (!overlap) accepted.push(cand);
}
// Sort left-to-right for reading order.
accepted.sort((a, b) => a.interval[0] - b.interval[0]);
let text = accepted.map(c => c.char).join('');
let chars = accepted.map(c => ({ char: c.char, bbox: c.bbox }));
({ text, chars } = fixSwappedPairs(text, chars));
return { text, chars };
}
// === FULL OCR PASS ========================================================
async function ocrRegion() {
const regionCanvas = grabRegionCanvas();
if (!regionCanvas) throw new Error('No scan region');
const t0 = performance.now();
const { boxes } = await runDetection(regionCanvas);
const tDetect = performance.now() - t0;
if (boxes.length === 0) {
log(`OCR: 0 boxes in ${tDetect.toFixed(0)}ms`);
return { text: '', lines: [], boxes: 0, tDetect, tRecog: 0 };
}
const t1 = performance.now();
const lines = [];
// Sequential rather than batched. ONNX-Runtime-Web doesn't get much from
// batching on WASM and sequential keeps memory pressure low. If this turns
// out to be the perf bottleneck we can revisit.
for (const box of boxes) {
const { text, chars } = await recognizeBox(regionCanvas, box);
if (!text) continue;
// Apply the Japanese-character filter here, per-line. If a line has no
// kana/kanji it gets dropped before it can pollute the output. UI chrome,
// English watermarks, romaji subs, etc.
if (!JAPANESE_REGEX.test(text)) {
log(` skip non-JP line: "${text}"`);
continue;
}
lines.push({ text, bbox: box, chars });
}
const tRecog = performance.now() - t1;
const text = lines.map(l => l.text).join('\n');
log(`OCR: ${boxes.length} det -> ${lines.length} JP line(s) "${text.replace(/\n/g, ' | ')}" (det ${tDetect.toFixed(0)}ms + rec ${tRecog.toFixed(0)}ms)`);
return { text, lines, boxes: boxes.length, tDetect, tRecog };
}
// capture(): one user-facing capture cycle. Returns { dispatched, lines }
// so the auto-loop knows whether to clear the dirty flag.
async function capture() {
if (!modelsReady) throw new Error('Models not loaded yet');
if (captureBusy) { log('capture() already running, skipping'); return { dispatched: false, suppressed: false, lines: [] }; }
captureBusy = true;
try {
if (!captureStream || !captureVideo) {
await initStream();
}
if (!scanRegion) {
ui.setStatus('Draw a scan region...');
const fullCanvas = grabFrame();
const region = await showRegionSelector(fullCanvas);
if (!region) {
ui.setStatus('No region set');
return { dispatched: false, suppressed: false, lines: [] };
}
}
ui.setStatus('Running OCR...');
const tStart = performance.now();
const result = await ocrRegion();
if (result.text.length === 0) {
ui.setStatus('No text found');
return { dispatched: false, suppressed: false, lines: [] };
}
return dispatch(result.text, result.lines, tStart);
} catch (e) {
log('capture() error:', e);
ui.setStatus('Error: ' + e.message);
throw e;
} finally {
captureBusy = false;
}
}
// Dispatch fires when we have text that isn't a repeat of the last line we
// sent. A repeat guard is needed because the force-OCR safety net re-reads a
// static screen every FORCE_OCR_INTERVAL_MS and would otherwise re-send a
// line that's simply still on screen. The guard is deliberately narrow: it
// only compares against the immediately preceding line, so genuinely shorter
// follow-on text still gets through.
//
// The comparison key strips whitespace and punctuation. Terminal punctuation
// is the least stable part of a recognition pass: 」 and 。 occupy a similar
// box, both get PUNCT_CONF_FACTOR applied, and a one-frame difference (a
// fade, a blinking advance cursor, anti-aliasing) can flip which one wins
// NMS. So the same on-screen line can come back as ...できる」 on one pass
// and ...できる。on the next. A strict string compare misses that; this
// doesn't. The dispatched text itself is never modified.
let lastDispatchedKey = null;
// Strips whitespace, punctuation (\p{P}) and symbols (\p{S}). Symbols matter:
// 」 misrecognized as | is a common wobble, and | is category Sm, not P, so a
// punctuation-only strip leaves it in and the keys diverge.
function dedupKey(s) {
return s.replace(/[\s\u3000]+/g, '').replace(/[\p{P}\p{S}]/gu, '');
}
// Levenshtein distance, abandoned as soon as it's provably above `max`.
// Returns max + 1 to signal "further than we care about".
function boundedEditDistance(aStr, bStr, max) {
if (aStr === bStr) return 0;
const a = [...aStr];
const b = [...bStr];
if (Math.abs(a.length - b.length) > max) return max + 1;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
let prev = new Array(b.length + 1);
let curr = new Array(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
curr[0] = i;
let rowMin = curr[0];
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
if (curr[j] < rowMin) rowMin = curr[j];
}
if (rowMin > max) return max + 1;
const swap = prev; prev = curr; curr = swap;
}
return prev[b.length];
}
// Returns { distance } when `key` should be suppressed, otherwise null.
function duplicateOf(key, prevKey) {
if (!key || !prevKey) return null;
if (key === prevKey) return { distance: 0 };
// Short lines get exact matching only. 「うん」 and 「ううん」 are one edit
// apart and are entirely different lines; a distance rule at that length
// would silently drop real dialogue.
const len = Math.max([...key].length, [...prevKey].length);
if (len < DEDUP_MIN_LENGTH) return null;
const distance = boundedEditDistance(key, prevKey, DEDUP_MAX_EDITS);
if (distance > DEDUP_MAX_EDITS) return null;
if (distance / len > DEDUP_MAX_RATIO) return null;
return { distance };
}
function dispatch(text, lines, tStart) {
const joined = lines.map(l => l.text).join('');
const key = dedupKey(joined);
const tTotal = performance.now() - tStart;
// A line of pure punctuation has an empty key; never let that match.
const dup = duplicateOf(key, lastDispatchedKey);
if (dup) {
log(`Suppressed repeat, ${dup.distance} edit(s) from previous (${tTotal.toFixed(0)}ms): ${joined}`);
ui.setStatus(dup.distance === 0
? 'Same as last line, skipped'
: `Near-duplicate (${dup.distance} char), skipped`);
// lastDispatchedKey deliberately not updated. Comparing against the last
// line actually sent stops a slowly drifting line from being suppressed
// forever, one edit at a time.
return { dispatched: false, suppressed: true, lines };
}
lastDispatchedKey = key;
log(`Dispatching kizuna:add-line (${tTotal.toFixed(0)}ms total): ${joined}`);
window.dispatchEvent(new CustomEvent('kizuna:add-line', { detail: { text: joined } }));
ui.setStatus(`Dispatched ${lines.length} line(s) in ${tTotal.toFixed(0)}ms`);
return { dispatched: true, suppressed: false, lines };
}
// === AUTO LOOP (state machine) ===========================================
const autoController = (function() {
let running = false;
let timer = null;
let lastSample = null;
let stableCount = 0;
let dirty = false;
let lastDispatchTime = 0;
async function tick() {
if (!running) return;
try {
await runTickBody();
} catch (e) {
log('Auto tick error:', e);
}
if (running) timer = setTimeout(tick, POLL_MS);
}
async function runTickBody() {
if (!captureStream || !captureVideo || !scanRegion) {
ui.setStatus('Waiting for region...');
return;
}
if (captureBusy) return;
const regionCanvas = grabRegionCanvas();
if (!regionCanvas) return;
const sample = sampleCanvas(regionCanvas);
const now = performance.now();
if (!lastSample) {
lastSample = sample;
return;
}
const ratio = diffSamples(lastSample, sample);
const changed = ratio >= CHANGE_RATIO;
if (changed) {
lastSample = sample;
stableCount = 0;
dirty = true;
ui.setStatus(`Changing (${(ratio * 100).toFixed(0)}%)...`);
return;
}
if (dirty) {
stableCount++;
ui.setStatus(`Settling ${stableCount}/${STABLE_TICKS}...`);
if (stableCount >= STABLE_TICKS) {
stableCount = 0;
ui.setStatus('Settled, reading');
log('[auto] settled, running OCR');
const result = await capture();
if (result.dispatched || result.suppressed) {
// A suppressed repeat still means we read the screen successfully
// and we're caught up — clear dirty, same as a real dispatch.
// Treating it as a failure would spin the settle loop forever.
dirty = false;
lastDispatchTime = performance.now();
} else {
// OCR ran but found nothing. Take a fresh sample so we don't
// immediately re-trigger on the same frame, but keep dirty=true
// so a real change OR the force-OCR safety net will re-try.
lastSample = sampleCanvas(grabRegionCanvas());
stableCount = 0;
log('[auto] OCR did not dispatch, keeping dirty=true');
}
}
return;
}
// Scene is stable and we believe we're caught up. Force-OCR safety net
// for cases where change detection itself missed (slow subtitle fade-in
// on a near-uniform background, for example).
ui.setStatus('Watching...');
const sinceDispatch = now - lastDispatchTime;
if (lastDispatchTime > 0 && sinceDispatch > FORCE_OCR_INTERVAL_MS) {
log(`[auto] force-OCR safety net (${(sinceDispatch/1000).toFixed(1)}s since last)`);
await capture();
// Reset the timer regardless of outcome so we don't hammer.
lastDispatchTime = performance.now();
}
}
return {
get running() { return running; },
start() {
if (running) return;
running = true;
lastSample = null;
stableCount = 0;
dirty = false;
lastDispatchTime = 0;
log(`Auto started (poll ${POLL_MS}ms, settle ${STABLE_TICKS} ticks, force-OCR ${FORCE_OCR_INTERVAL_MS}ms)`);
tick();
},
stop() {
if (!running) return;
running = false;
clearTimeout(timer);
timer = null;
log('Auto stopped');
},
reset() {
lastSample = null;
stableCount = 0;
dirty = false;
lastDispatchTime = 0;
// New region or manual reset: the same text should be readable again.
lastDispatchedKey = null;
},
};
})();
// === PUBLIC API ===========================================================
window.meikiOcr = {
capture: () => {
const p = capture();
p.then(({ lines }) => {
if (typeof window.onMeikiOcrResult === 'function') window.onMeikiOcrResult(lines);
}).catch(e => ui.setStatus('Error: ' + e.message));
return p;
},
isReady: () => modelsReady,
startAuto: () => autoController.start(),
stopAuto: () => autoController.stop(),
};
window.addEventListener('meiki-ocr-capture', () => capture());
// === INIT =================================================================
try {
await loadModels();
} catch (e) {
ui.setStatus('Failed to load models: ' + e.message);
console.error('[MeikiOCR] Model load error:', e);
}
// === REGION SELECTOR ======================================================
function showRegionSelector(frameCanvas) {
return new Promise((resolve) => {
const dataUrl = frameCanvas.toDataURL('image/jpeg', 0.85);
const vidW = frameCanvas.width;
const vidH = frameCanvas.height;
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
z-index:2147483646;
background:#000 url('${dataUrl}') center/contain no-repeat;
cursor:crosshair;user-select:none;
`;
const vignette = document.createElement('div');
vignette.style.cssText = `position:absolute;inset:0;background:rgba(0,0,0,0.45);pointer-events:none;`;
overlay.appendChild(vignette);
const label = document.createElement('div');
label.textContent = 'Drag to select a scan region. Enter or double-click to confirm, Esc to cancel.';
label.style.cssText = `
position:absolute;top:16px;left:50%;transform:translateX(-50%);
background:#111827;color:#d1d5db;
font-family:system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;font-size:13px;
padding:6px 16px;border-radius:0.25rem;border:1px solid #374151;
pointer-events:none;white-space:nowrap;
`;
overlay.appendChild(label);
const rect = document.createElement('div');
rect.style.cssText = `
position:absolute;border:2px solid #3b82f6;
box-shadow:0 0 0 9999px rgba(0,0,0,0.35);
pointer-events:none;display:none;box-sizing:border-box;
`;
overlay.appendChild(rect);
// Both actions sit in one centered flex row. Positioning them
// individually meant the gap was a center-to-center offset, so the real
// spacing depended on how wide each label happened to render, and the
// pair as a whole sat off-center.
const actionRow = document.createElement('div');
actionRow.style.cssText = `
position:absolute;bottom:16px;left:50%;transform:translateX(-50%);
display:flex;gap:8px;
`;
const btnBase = `
padding:8px 16px;border-radius:0.25rem;cursor:pointer;display:none;
font-family:system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;font-size:13px;
`;
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Confirm region';
confirmBtn.style.cssText = btnBase + `
background:#1f2937;color:#60a5fa;border:1px solid #3b82f6;
`;
actionRow.appendChild(confirmBtn);
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear region';
clearBtn.style.cssText = btnBase + `
background:#1f2937;color:#9ca3af;border:1px solid #374151;
`;
actionRow.appendChild(clearBtn);
overlay.appendChild(actionRow);
document.body.appendChild(overlay);
function getImageRect() {
const vw = window.innerWidth, vh = window.innerHeight;
const scale = Math.min(vw / vidW, vh / vidH);
const dispW = vidW * scale, dispH = vidH * scale;
return { left: (vw - dispW) / 2, top: (vh - dispH) / 2, width: dispW, height: dispH, scale };
}
function toVideoCoords(clientX, clientY) {
const r = getImageRect();
return {
x: Math.round((clientX - r.left) / r.scale),
y: Math.round((clientY - r.top) / r.scale),
};
}
let dragStart = null;
let currentSel = null;
function updateRect(x1, y1, x2, y2) {
const r = getImageRect();
const cx1 = Math.max(r.left, Math.min(r.left + r.width, x1));
const cy1 = Math.max(r.top, Math.min(r.top + r.height, y1));
const cx2 = Math.max(r.left, Math.min(r.left + r.width, x2));
const cy2 = Math.max(r.top, Math.min(r.top + r.height, y2));
const left = Math.min(cx1, cx2);
const top = Math.min(cy1, cy2);
const width = Math.abs(cx2 - cx1);
const height = Math.abs(cy2 - cy1);
rect.style.left = left + 'px';
rect.style.top = top + 'px';
rect.style.width = width + 'px';
rect.style.height = height + 'px';
rect.style.display = width > 4 && height > 4 ? 'block' : 'none';
const vc1 = toVideoCoords(left, top);
const vc2 = toVideoCoords(left + width, top + height);
currentSel = {
x: Math.max(0, vc1.x),
y: Math.max(0, vc1.y),
w: Math.min(vidW, vc2.x) - Math.max(0, vc1.x),
h: Math.min(vidH, vc2.y) - Math.max(0, vc1.y),
};
label.textContent = `${currentSel.w} × ${currentSel.h}px — Enter or double-click to confirm, Esc to cancel`;
}
function swallow(e) { e.stopPropagation(); }
[confirmBtn, clearBtn].forEach(btn => {
btn.addEventListener('mousedown', swallow);
btn.addEventListener('mouseup', swallow);
});
overlay.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (e.target !== overlay && e.target !== vignette) return;
dragStart = { x: e.clientX, y: e.clientY };
confirmBtn.style.display = 'none';
clearBtn.style.display = 'none';
currentSel = null;
});
overlay.addEventListener('mousemove', (e) => {
if (!dragStart) return;
updateRect(dragStart.x, dragStart.y, e.clientX, e.clientY);
});
overlay.addEventListener('mouseup', (e) => {
if (!dragStart) return;
updateRect(dragStart.x, dragStart.y, e.clientX, e.clientY);
dragStart = null;
if (currentSel && currentSel.w > 4 && currentSel.h > 4) {
confirmBtn.style.display = 'block';
clearBtn.style.display = 'block';
}
});
overlay.addEventListener('dblclick', () => {
if (currentSel && currentSel.w > 4) confirm();
});
function cleanup() {
document.removeEventListener('keydown', onKey);
overlay.remove();
}
function confirm() {
scanRegion = currentSel;
autoController.reset();
log('Scan region set:', JSON.stringify(scanRegion));
cleanup();
resolve(scanRegion);
}
function cancel() {
cleanup();
resolve(null);
}
function clearRegion() {
scanRegion = null;
autoController.reset();
rect.style.display = 'none';
confirmBtn.style.display = 'none';
clearBtn.style.display = 'none';
currentSel = null;
label.textContent = 'Region cleared. Drag to set a new one, or press Esc to close.';
log('Scan region cleared');
setTimeout(cleanup, 800);
resolve(null);
}
confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); confirm(); });
clearBtn.addEventListener('click', (e) => { e.stopPropagation(); clearRegion(); });
function onKey(e) {
if (e.key === 'Escape') cancel();
else if (e.key === 'Enter' && currentSel) confirm();
}
document.addEventListener('keydown', onKey);
});
}
// === UI ===================================================================
function createUI() {
const panel = document.createElement('div');
panel.id = 'meiki-ocr-panel';
panel.innerHTML = `
<style>
/* Palette mirrors the Kizuna page: gray-900 surfaces, gray-700
borders, gray-800 buttons, blue-400 accent, red-400 for active. */
#meiki-ocr-panel {
position: fixed; bottom: 16px; right: 16px;
z-index: 2147483647;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
pointer-events: none;
}
#meiki-ocr-panel button {
pointer-events: all;
font-family: inherit; font-size: 13px;
padding: 8px 12px; border-radius: 0.25rem;
background: #1f2937; color: #e5e7eb; border: 1px solid #374151;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
#meiki-ocr-panel button:not(:disabled):hover { background: #374151; }
#meiki-ocr-panel button:disabled { cursor: default; opacity: 0.4; }
#meiki-ocr-panel button:focus-visible {
outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6;
}
#meiki-ocr-status {
background: #111827; color: #9ca3af;
border: 1px solid #374151; padding: 4px 12px; border-radius: 0.25rem;
max-width: 280px; text-align: right; transition: opacity 0.3s;
}
/* Primary action carries the blue accent. */
#meiki-ocr-btn { color: #60a5fa; }
#meiki-ocr-btn:not(:disabled):hover { color: #93c5fd; }
/* Running is a stop action, so it takes the page's destructive style. */
#meiki-ocr-auto.running {
color: #f87171;
background: rgba(127, 29, 29, 0.3);
border-color: rgba(127, 29, 29, 0.5);
}
#meiki-ocr-auto.running:not(:disabled):hover {
background: rgba(127, 29, 29, 0.5);
}
/* Set once a scan region exists. */
#meiki-ocr-region.active { color: #60a5fa; border-color: #3b82f6; }
</style>
<div id="meiki-ocr-status">Initializing...</div>
<div style="display:flex;gap:6px;">
<button id="meiki-ocr-reset" title="Clear region">Reset</button>
<button id="meiki-ocr-region" title="Set scan region">Region</button>
<button id="meiki-ocr-btn" disabled>OCR</button>
<button id="meiki-ocr-auto" disabled title="Start auto OCR">Run</button>
</div>
`;
const attach = () => {
if (document.body) document.body.appendChild(panel);
else document.addEventListener('DOMContentLoaded', () => document.body.appendChild(panel));
};
attach();
const statusEl = () => panel.querySelector('#meiki-ocr-status');
const btnEl = () => panel.querySelector('#meiki-ocr-btn');
const autoBtnEl = () => panel.querySelector('#meiki-ocr-auto');
// Recognized text goes to the kizuna:add-line event and the console. The
// status line reports the count; we deliberately don't mirror the text
// on screen, since it's already added to the page.
async function runOnce() {
try {
await capture();
} catch (e) {
log('runOnce error:', e);
}
}
function refreshAutoBtn() {
const b = autoBtnEl();
if (!b) return;
if (autoController.running) {
b.textContent = 'Stop';
b.classList.add('running');
b.title = 'Stop auto mode';
} else {
b.textContent = 'Run';
b.classList.remove('running');
b.title = 'Start auto mode';
}
}
panel.addEventListener('click', (e) => {
if (e.target.id === 'meiki-ocr-auto' && !e.target.disabled) {
if (autoController.running) autoController.stop();
else autoController.start();
refreshAutoBtn();
}
if (e.target.id === 'meiki-ocr-btn' && !e.target.disabled) {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Running...';
runOnce().then(() => {
if (scanRegion && !autoController.running) {
autoController.start();
refreshAutoBtn();
}
}).finally(() => {
btn.disabled = false;
btn.textContent = 'Capture OCR';
});
}
if (e.target.id === 'meiki-ocr-region') {
if (!captureStream || !captureVideo) {
ui.setStatus('Start a capture first, then set region');
return;
}
const btn = e.target;
scanRegion = null;
const canvas = grabFrame();
showRegionSelector(canvas).then(region => {
if (region) {
btn.classList.add('active');
btn.title = `Scan region: ${region.w}x${region.h} — click to change`;
ui.setStatus(`Region set to ${region.w}×${region.h}, watching`);
if (!autoController.running) { autoController.start(); refreshAutoBtn(); }
} else {
btn.classList.remove('active');
btn.title = 'Set scan region';
ui.setStatus('No region — will prompt on next capture');
}
}).catch(e => log('Region selector error:', e));
}
if (e.target.id === 'meiki-ocr-reset') {
scanRegion = null;
autoController.reset();
const rb = panel.querySelector('#meiki-ocr-region');
if (rb) { rb.classList.remove('active'); rb.title = 'Set scan region'; }
ui.setStatus('Region cleared');
log('Region cleared, state reset');
}
});
let statusTimeout;
return {
setStatus(msg) {
const el = statusEl();
if (el) { el.textContent = msg; el.style.opacity = '1'; }
clearTimeout(statusTimeout);
if (modelsReady) {
statusTimeout = setTimeout(() => { if (el) el.style.opacity = '0.5'; }, 4000);
}
},
setReady(ready) {
const btn = btnEl();
if (btn) {
btn.disabled = !ready;
btn.textContent = ready ? 'Capture OCR' : 'Loading...';
}
const autoBtn = autoBtnEl();
if (autoBtn) autoBtn.disabled = !ready;
},
};
}
// === UTILS ================================================================
function arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
})();
// ==UserScript==
// @name Meiki OCR
// @namespace meiki-ocr
// @version 1.0.0
// @description Screen capture OCR using meiki.text.detect.v0.1 + meiki.text.rec.v0 ONNX models. Pipeline ported from the meikiocr Python library.
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect huggingface.co
// @connect hf.co
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/ort.min.js
// ==/UserScript==
/**
* Local Japanese OCR for the Kizuna texthooker.
*
* Captures a screen region with getDisplayMedia, runs a two-stage ONNX pipeline
* in the browser, and dispatches recognized lines to the page as a
* `kizuna:add-line` CustomEvent. Nothing leaves the machine.
*
* detection meiki.text.detect.v0.1.320x192 — finds text line boxes
* recognition meiki.text.rec.v0.960x32 — reads characters in each box
*
* Preprocessing and postprocessing follow the meikiocr Python library:
* https://github.com/rtr46/meikiocr
*
* Auto mode watches the region on a sampling grid and only runs OCR once the
* picture has settled, so a text box mid-animation isn't read half-drawn.
*
* Limitations: horizontal text only. Detection is capped at 64 boxes and
* recognition at 48 characters per line, which is ample for game text but not
* for dense pages. Trained on rendered game text, so handwriting and photos
* will disappoint.
*/
(async function () {
'use strict';
// === CONFIG ===============================================================
// Detection: v0.1.320x192 — newer architecture (MobileNet v4 small + D-FINE),
// RGB input, stretch-resize (no letterbox), per-axis ratio unmap. ~30ms
// native CPU per the model card; expect ~2-4x in WASM.
const DETECT_MODEL_URL =
'https://huggingface.co/rtr46/meiki.text.detect.v0/resolve/main/meiki.text.detect.v0.1.320x192.onnx';
const DETECT_W = 320;
const DETECT_H = 192;
const DET_CONF_THRESHOLD = 0.4;
// Overlap above which two detected boxes are treated as the same text line.
const DET_OVERLAP_THRESHOLD = 0.6;
// Recognition: meiki.text.rec.v0 horizontal model. Input is 960x32 RGB,
// aspect-preserving fit with bottom+right zero padding.
const RECOG_MODEL_URL =
'https://huggingface.co/rtr46/meiki.txt.recognition.v0/resolve/main/meiki.text.rec.v0.960x32.onnx';
const RECOG_W = 960;
const RECOG_H = 32;
const REC_CONF_THRESHOLD = 0.1;
// Postprocessing knobs from the meikiocr library.
const PUNCT_CONF_FACTOR = 0.2;
const X_OVERLAP_THRESHOLD = 0.3;
const EPSILON = 1e-6;
// Repeat suppression. A line is dropped if it's too close to the previous
// dispatched line — this absorbs recognition wobble (a bracket read as a
// pipe, a clipped leading 「, a dropped character) that would otherwise
// arrive as a near-identical duplicate.
//
// All three conditions must hold before a *near* match is suppressed; an
// exact match is always suppressed regardless of length. Raising MAX_EDITS
// catches more wobble but starts eating real lines, so widen MIN_LENGTH at
// the same time if you do.
const DEDUP_MAX_EDITS = 2; // absolute edit-distance ceiling
const DEDUP_MAX_RATIO = 0.1; // and no more than this fraction of the line
const DEDUP_MIN_LENGTH = 10; // shorter lines use exact matching only
// Drop any line that contains zero kana/kanji. This is what meikipop's
// provider does — it kills English UI, watermarks, romaji subtitles, etc.
// Hiragana U+3040–309F, Katakana U+30A0–30FF, CJK Unified U+4E00–9FAF.
const JAPANESE_REGEX = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
// Swapped-pair fixes (pairs the model is known to emit reversed).
const SWAPPED_PAIRS = {
'儡傀': '傀儡',
'談冗': '冗談',
'汰淘': '淘汰',
'沱滂': '滂沱',
'攣痙': '痙攣',
'酊酩': '酩酊',
'麭麺': '麺麭',
'哭慟': '慟哭',
};
// Change detection.
const SAMPLE_COLS = 48;
const SAMPLE_ROWS = 24;
const CHANGE_RATIO = 0.03;
const PIXEL_THRESHOLD = 18;
const POLL_MS = 80;
const STABLE_TICKS = 5;
const FORCE_OCR_INTERVAL_MS = 4000;
// === LOGGER ===============================================================
function log(...args) { console.log('[MeikiOCR]', ...args); }
// === UI ===================================================================
const ui = createUI();
// === MODEL LOADING ========================================================
let detectSession = null;
let recogSession = null;
let modelsReady = false;
async function loadModels() {
ui.setStatus('Loading models...');
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/';
const [detectBuf, recogBuf] = await Promise.all([
fetchModelCached('detect_v01_320x192', DETECT_MODEL_URL),
fetchModelCached('recog_v0_960x32', RECOG_MODEL_URL),
]);
ui.setStatus('Creating ONNX sessions...');
detectSession = await ort.InferenceSession.create(detectBuf, { executionProviders: ['wasm'] });
recogSession = await ort.InferenceSession.create(recogBuf, { executionProviders: ['wasm'] });
modelsReady = true;
ui.setStatus('Ready');
ui.setReady(true);
log('Models loaded.');
}
// Cache key is keyed to the model identity, so changing model URLs above
// implicitly invalidates the old cache rather than serving stale weights.
async function fetchModelCached(key, url) {
const cacheKey = `meiki_model_${key}`;
ui.setStatus(`Checking cache for ${key}...`);
try {
const cached = GM_getValue(cacheKey, null);
if (cached) {
ui.setStatus(`Loading ${key} from cache...`);
return base64ToArrayBuffer(cached);
}
} catch (e) { /* cache miss */ }
ui.setStatus(`Downloading ${key} (first run only)...`);
const buf = await fetchArrayBuffer(url, (pct) => {
ui.setStatus(`Downloading ${key}... ${pct}%`);
});
try { GM_setValue(cacheKey, arrayBufferToBase64(buf)); }
catch (e) { console.warn('[MeikiOCR] Could not cache model:', e); }
return buf;
}
function fetchArrayBuffer(url, onProgress) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
onprogress: (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
},
onload: (r) => {
if (r.status >= 200 && r.status < 300) resolve(r.response);
else reject(new Error(`HTTP ${r.status} for ${url}`));
},
onerror: reject,
});
});
}
// === CAPTURE STREAM =======================================================
let captureStream = null;
let captureVideo = null;
let scanRegion = null;
let captureBusy = false;
async function initStream() {
captureStream = await navigator.mediaDevices.getDisplayMedia({
video: { mediaSource: 'window' },
audio: false,
});
captureVideo = document.createElement('video');
captureVideo.srcObject = captureStream;
captureVideo.autoplay = true;
captureVideo.muted = true;
captureVideo.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;';
document.body.appendChild(captureVideo);
await new Promise((resolve) => { captureVideo.onloadedmetadata = resolve; });
log(`Stream ready: ${captureVideo.videoWidth}x${captureVideo.videoHeight}`);
captureStream.getTracks()[0].addEventListener('ended', () => {
log('Stream ended');
captureVideo?.remove();
captureStream = null;
captureVideo = null;
if (autoController.running) autoController.stop();
});
}
function grabFrame() {
const canvas = document.createElement('canvas');
canvas.width = captureVideo.videoWidth;
canvas.height = captureVideo.videoHeight;
canvas.getContext('2d').drawImage(captureVideo, 0, 0);
return canvas;
}
function grabRegionCanvas() {
if (!scanRegion) return null;
const { x, y, w, h } = scanRegion;
const full = grabFrame();
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(full, x, y, w, h, 0, 0, w, h);
return c;
}
// === CHANGE DETECTION =====================================================
function sampleCanvas(canvas) {
const W = canvas.width, H = canvas.height;
const data = canvas.getContext('2d').getImageData(0, 0, W, H).data;
const out = new Float32Array(SAMPLE_COLS * SAMPLE_ROWS);
for (let r = 0; r < SAMPLE_ROWS; r++) {
for (let c = 0; c < SAMPLE_COLS; c++) {
const px = Math.floor((c + 0.5) * W / SAMPLE_COLS);
const py = Math.floor((r + 0.5) * H / SAMPLE_ROWS);
const i = (py * W + px) * 4;
out[r * SAMPLE_COLS + c] = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
}
}
return out;
}
function diffSamples(a, b) {
let changed = 0;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > PIXEL_THRESHOLD) changed++;
}
return changed / a.length;
}
// === DETECTION ============================================================
//
// Mirrors meiki.text.detect.v0.1.320x192's official inference script:
// - cv2.resize to 320x192 with INTER_LINEAR (we use canvas drawImage which
// is bilinear by default)
// - per-axis ratios — no letterbox/aspect preservation
// - normalize /255, RGB, NCHW
// - second "orig_target_sizes" input = [[320, 192]] int64 (model unmaps
// coordinates back into 320x192 space internally; we then apply per-axis
// ratios to get back to source coords)
// Overlap of two boxes as a fraction of the smaller one's area.
function boxOverlapRatio(a, b) {
const ix = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0]));
const iy = Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1]));
const inter = ix * iy;
if (inter <= 0) return 0;
const areaA = (a[2] - a[0]) * (a[3] - a[1]);
const areaB = (b[2] - b[0]) * (b[3] - b[1]);
return inter / (Math.min(areaA, areaB) + EPSILON);
}
async function runDetection(img) {
const origW = img.width;
const origH = img.height;
const offscreen = new OffscreenCanvas(DETECT_W, DETECT_H);
const ctx = offscreen.getContext('2d');
// Stretch — no aspect preservation. The model was trained this way.
ctx.drawImage(img, 0, 0, origW, origH, 0, 0, DETECT_W, DETECT_H);
const imageData = ctx.getImageData(0, 0, DETECT_W, DETECT_H);
const pixels = imageData.data;
// RGB, NCHW, /255.
const chSize = DETECT_W * DETECT_H;
const tensor = new Float32Array(3 * chSize);
for (let i = 0; i < chSize; i++) {
tensor[0 * chSize + i] = pixels[i * 4 + 0] / 255.0;
tensor[1 * chSize + i] = pixels[i * 4 + 1] / 255.0;
tensor[2 * chSize + i] = pixels[i * 4 + 2] / 255.0;
}
const imageTensor = new ort.Tensor('float32', tensor, [1, 3, DETECT_H, DETECT_W]);
const sizesTensor = new ort.Tensor(
'int64',
BigInt64Array.from([BigInt(DETECT_W), BigInt(DETECT_H)]),
[1, 2],
);
const inputNames = detectSession.inputNames;
const feeds = {
[inputNames[0]]: imageTensor,
[inputNames[1]]: sizesTensor,
};
const results = await detectSession.run(feeds);
// Model returns three outputs (labels, boxes, scores). We don't care about
// labels for detection. Order in outputNames isn't guaranteed across model
// revisions, so look them up by shape rather than position: boxes is the
// 3-D tensor (batch, N, 4); scores is 2-D (batch, N).
let boxesOut = null;
let scoresOut = null;
for (const name of detectSession.outputNames) {
const t = results[name];
if (t.dims.length === 3 && t.dims[2] === 4) boxesOut = t;
else if (t.dims.length === 2) scoresOut = t;
}
if (!boxesOut || !scoresOut) {
throw new Error('Unexpected detection output shapes: ' +
detectSession.outputNames.map(n => `${n}=${results[n].dims.join('x')}`).join(', '));
}
const rawBoxes = boxesOut.data;
const rawScores = scoresOut.data;
const numBoxes = rawScores.length;
const ratioW = DETECT_W / origW;
const ratioH = DETECT_H / origH;
const scored = [];
for (let i = 0; i < numBoxes; i++) {
const score = rawScores[i];
if (score < DET_CONF_THRESHOLD) continue;
// Coords are in 320x192 space; divide by per-axis ratio to get back to
// source coords. This is what the official inference script does.
const x1 = Math.max(0, Math.min(origW, Math.round(rawBoxes[i * 4 + 0] / ratioW)));
const y1 = Math.max(0, Math.min(origH, Math.round(rawBoxes[i * 4 + 1] / ratioH)));
const x2 = Math.max(0, Math.min(origW, Math.round(rawBoxes[i * 4 + 2] / ratioW)));
const y2 = Math.max(0, Math.min(origH, Math.round(rawBoxes[i * 4 + 3] / ratioH)));
if (x2 <= x1 || y2 <= y1) continue;
scored.push({ box: [x1, y1, x2, y2], score });
}
// Suppress overlapping detections. D-FINE is a DETR-style detector with no
// NMS in its head, so several queries can lock onto the same text line and
// the official inference script's confidence threshold alone won't separate
// them. Two boxes on one line means that line gets recognized twice and,
// because dispatch() joins lines with '', the sentence comes back doubled
// with no visible seam.
//
// Denominator is min(areaA, areaB) rather than the union, so a box wholly
// contained in a larger one is caught too — union-IoU would score that pair
// low and let it through. Stacked text lines in a VN box overlap very
// little vertically, so this doesn't merge real neighbors.
scored.sort((a, b) => b.score - a.score);
const kept = [];
for (const cand of scored) {
let duplicate = false;
for (const k of kept) {
if (boxOverlapRatio(cand.box, k.box) > DET_OVERLAP_THRESHOLD) { duplicate = true; break; }
}
if (duplicate) {
log(` suppressed overlapping box ${cand.box.join(',')} (conf ${cand.score.toFixed(2)})`);
continue;
}
kept.push(cand);
}
const boxes = kept.map(k => k.box);
// Sort top-to-bottom so reading order is natural for horizontal text.
boxes.sort((a, b) => a[1] - b[1]);
return { boxes, origW, origH };
}
// === RECOGNITION ==========================================================
//
// For each detected line:
// 1. Crop from the region canvas using the source-coord bbox.
// 2. Scale so height = 32, preserving aspect ratio. If the resulting width
// would exceed 960, scale BOTH dimensions down so width fits — height
// ends up < 32 in this case. Track both effective_w and effective_h.
// 3. Pad bottom and right with zeros to 960x32.
// 4. Run model. Output is (labels, boxes, scores), where boxes are in the
// 960x32 recognition space.
// 5. Discard candidates that fall in the padding region, map coords back
// into the crop, then translate by the crop origin to get global coords.
// 6. Apply punct confidence factor, NMS by x-overlap (symmetric denominator),
// sort left-to-right, then apply the swapped-pair fix.
function isPunct(ch) {
// Unicode category "P*" covers ASCII punctuation, fullwidth punctuation,
// brackets, quotes, etc. The Python `unicodedata.category()` reference
// uses this check; in JS we approximate via Unicode property escape.
return /\p{P}/u.test(ch);
}
function fixSwappedPairs(text, chars) {
for (const [wrong, correct] of Object.entries(SWAPPED_PAIRS)) {
let idx = text.indexOf(wrong);
while (idx !== -1 && idx + 1 < chars.length) {
text = text.slice(0, idx) + correct + text.slice(idx + 2);
// Swap the char entries so bboxes line up with the corrected order.
const tmp = chars[idx].char;
chars[idx].char = chars[idx + 1].char;
chars[idx + 1].char = tmp;
idx = text.indexOf(wrong, idx + 2);
}
}
return { text, chars };
}
async function recognizeBox(regionImg, box) {
const [x1, y1, x2, y2] = box;
const cropW = Math.max(1, x2 - x1);
const cropH = Math.max(1, y2 - y1);
// Resize-with-aspect into the 960x32 frame.
let scale = RECOG_H / cropH;
let newW = Math.round(cropW * scale);
let newH = RECOG_H;
if (newW > RECOG_W) {
const sx = RECOG_W / newW;
newW = RECOG_W;
newH = Math.round(newH * sx); // < 32
}
const effectiveW = newW;
const effectiveH = newH;
// Crop then draw scaled into the top-left of a 960x32 canvas. The rest
// of the canvas stays black (the canvas is freshly allocated and JS
// initializes pixels to transparent black; we explicitly fill to be sure).
const recogCanvas = new OffscreenCanvas(RECOG_W, RECOG_H);
const recogCtx = recogCanvas.getContext('2d');
recogCtx.fillStyle = '#000';
recogCtx.fillRect(0, 0, RECOG_W, RECOG_H);
recogCtx.drawImage(regionImg, x1, y1, cropW, cropH, 0, 0, newW, newH);
const imageData = recogCtx.getImageData(0, 0, RECOG_W, RECOG_H);
const pixels = imageData.data;
const chSize = RECOG_W * RECOG_H;
const tensor = new Float32Array(3 * chSize);
for (let i = 0; i < chSize; i++) {
tensor[0 * chSize + i] = pixels[i * 4 + 0] / 255.0;
tensor[1 * chSize + i] = pixels[i * 4 + 1] / 255.0;
tensor[2 * chSize + i] = pixels[i * 4 + 2] / 255.0;
}
const imageTensor = new ort.Tensor('float32', tensor, [1, 3, RECOG_H, RECOG_W]);
const sizesTensor = new ort.Tensor(
'int64',
BigInt64Array.from([BigInt(RECOG_W), BigInt(RECOG_H)]),
[1, 2],
);
const inputNames = recogSession.inputNames;
const feeds = {
[inputNames[0]]: imageTensor,
[inputNames[1]]: sizesTensor,
};
const outputs = await recogSession.run(feeds);
const labels = outputs['char_codes'].data;
const rawBx = outputs['boxes'].data;
const scores = outputs['scores'].data;
const numDet = scores.length;
const candidates = [];
for (let i = 0; i < numDet; i++) {
const score = scores[i];
if (score < REC_CONF_THRESHOLD) continue;
let rx1 = rawBx[i * 4 + 0];
let ry1 = rawBx[i * 4 + 1];
let rx2 = rawBx[i * 4 + 2];
let ry2 = rawBx[i * 4 + 3];
// Drop candidates whose left edge is in the right padding region.
if (rx1 >= effectiveW) continue;
// Same for top edge — only matters when effectiveH < 32.
if (ry1 >= effectiveH) continue;
rx1 = Math.min(rx1, effectiveW);
rx2 = Math.min(rx2, effectiveW);
ry1 = Math.min(ry1, effectiveH);
ry2 = Math.min(ry2, effectiveH);
// Map back from recognition space -> crop space, then translate to
// region-coords (which is the global coord space we report to callers).
const cx1 = (rx1 / effectiveW) * cropW;
const cx2 = (rx2 / effectiveW) * cropW;
const cy1 = (ry1 / effectiveH) * cropH;
const cy2 = (ry2 / effectiveH) * cropH;
const gx1 = Math.round(x1 + cx1);
const gy1 = Math.round(y1 + cy1);
const gx2 = Math.round(x1 + cx2);
const gy2 = Math.round(y1 + cy2);
// Validate before decoding. The recognition head emits raw Unicode
// codepoints, but a low-confidence prediction can come back as a value
// that isn't a decodable scalar — out of range, or a lone surrogate,
// which String.fromCodePoint turns into an unpaired surrogate that
// renders as U+FFFD. Those were showing up in place of 「 and 」. Drop
// them and log the raw value so the cause is visible.
const raw = typeof labels[i] === 'bigint' ? Number(labels[i]) : labels[i];
if (!Number.isInteger(raw) || raw < 0x20 || raw > 0x10FFFF ||
(raw >= 0xD800 && raw <= 0xDFFF)) {
log(` dropped undecodable codepoint ${raw} (conf ${score.toFixed(2)})`);
continue;
}
const char = String.fromCodePoint(raw);
if (char === '\uFFFD') {
log(` dropped replacement char from codepoint ${raw} (conf ${score.toFixed(2)})`);
continue;
}
// Punctuation gets its confidence multiplied down so overlapping real
// characters win the NMS tiebreak. We mutate conf BEFORE sorting.
const adjConf = isPunct(char) ? score * PUNCT_CONF_FACTOR : score;
candidates.push({
char,
bbox: [gx1, gy1, gx2, gy2],
conf: adjConf,
// x-interval in crop space is what we NMS over (avoids x1 ties from
// adjacent boxes with different crop origins — though that's moot for
// a single-line single-box case, this keeps the semantics local).
interval: [cx1, cx2],
});
}
// Greedy NMS by confidence with symmetric overlap (min of the two widths
// as denominator). This is the form the library uses, not the form in
// the official inference script.
candidates.sort((a, b) => b.conf - a.conf);
const accepted = [];
for (const cand of candidates) {
const [i1c, i2c] = cand.interval;
const lenC = i2c - i1c + EPSILON;
let overlap = false;
for (const acc of accepted) {
const [i1a, i2a] = acc.interval;
if (i1c >= i2a || i1a >= i2c) continue;
const interLen = Math.max(0, Math.min(i2c, i2a) - Math.max(i1c, i1a));
const lenA = i2a - i1a + EPSILON;
const denom = Math.min(lenC, lenA);
if (interLen / denom > X_OVERLAP_THRESHOLD) { overlap = true; break; }
}
if (!overlap) accepted.push(cand);
}
// Sort left-to-right for reading order.
accepted.sort((a, b) => a.interval[0] - b.interval[0]);
let text = accepted.map(c => c.char).join('');
let chars = accepted.map(c => ({ char: c.char, bbox: c.bbox }));
({ text, chars } = fixSwappedPairs(text, chars));
return { text, chars };
}
// === FULL OCR PASS ========================================================
async function ocrRegion() {
const regionCanvas = grabRegionCanvas();
if (!regionCanvas) throw new Error('No scan region');
const t0 = performance.now();
const { boxes } = await runDetection(regionCanvas);
const tDetect = performance.now() - t0;
if (boxes.length === 0) {
log(`OCR: 0 boxes in ${tDetect.toFixed(0)}ms`);
return { text: '', lines: [], boxes: 0, tDetect, tRecog: 0 };
}
const t1 = performance.now();
const lines = [];
// Sequential rather than batched. ONNX-Runtime-Web doesn't get much from
// batching on WASM and sequential keeps memory pressure low. If this turns
// out to be the perf bottleneck we can revisit.
for (const box of boxes) {
const { text, chars } = await recognizeBox(regionCanvas, box);
if (!text) continue;
// Apply the Japanese-character filter here, per-line. If a line has no
// kana/kanji it gets dropped before it can pollute the output. UI chrome,
// English watermarks, romaji subs, etc.
if (!JAPANESE_REGEX.test(text)) {
log(` skip non-JP line: "${text}"`);
continue;
}
lines.push({ text, bbox: box, chars });
}
const tRecog = performance.now() - t1;
const text = lines.map(l => l.text).join('\n');
log(`OCR: ${boxes.length} det -> ${lines.length} JP line(s) "${text.replace(/\n/g, ' | ')}" (det ${tDetect.toFixed(0)}ms + rec ${tRecog.toFixed(0)}ms)`);
return { text, lines, boxes: boxes.length, tDetect, tRecog };
}
// capture(): one user-facing capture cycle. Returns { dispatched, lines }
// so the auto-loop knows whether to clear the dirty flag.
async function capture() {
if (!modelsReady) throw new Error('Models not loaded yet');
if (captureBusy) { log('capture() already running, skipping'); return { dispatched: false, suppressed: false, lines: [] }; }
captureBusy = true;
try {
if (!captureStream || !captureVideo) {
await initStream();
}
if (!scanRegion) {
ui.setStatus('Draw a scan region...');
const fullCanvas = grabFrame();
const region = await showRegionSelector(fullCanvas);
if (!region) {
ui.setStatus('No region set');
return { dispatched: false, suppressed: false, lines: [] };
}
}
ui.setStatus('Running OCR...');
const tStart = performance.now();
const result = await ocrRegion();
if (result.text.length === 0) {
ui.setStatus('No text found');
return { dispatched: false, suppressed: false, lines: [] };
}
return dispatch(result.text, result.lines, tStart);
} catch (e) {
log('capture() error:', e);
ui.setStatus('Error: ' + e.message);
throw e;
} finally {
captureBusy = false;
}
}
// Dispatch fires when we have text that isn't a repeat of the last line we
// sent. A repeat guard is needed because the force-OCR safety net re-reads a
// static screen every FORCE_OCR_INTERVAL_MS and would otherwise re-send a
// line that's simply still on screen. The guard is deliberately narrow: it
// only compares against the immediately preceding line, so genuinely shorter
// follow-on text still gets through.
//
// The comparison key strips whitespace and punctuation. Terminal punctuation
// is the least stable part of a recognition pass: 」 and 。 occupy a similar
// box, both get PUNCT_CONF_FACTOR applied, and a one-frame difference (a
// fade, a blinking advance cursor, anti-aliasing) can flip which one wins
// NMS. So the same on-screen line can come back as ...できる」 on one pass
// and ...できる。on the next. A strict string compare misses that; this
// doesn't. The dispatched text itself is never modified.
let lastDispatchedKey = null;
// Strips whitespace, punctuation (\p{P}) and symbols (\p{S}). Symbols matter:
// 」 misrecognized as | is a common wobble, and | is category Sm, not P, so a
// punctuation-only strip leaves it in and the keys diverge.
function dedupKey(s) {
return s.replace(/[\s\u3000]+/g, '').replace(/[\p{P}\p{S}]/gu, '');
}
// Levenshtein distance, abandoned as soon as it's provably above `max`.
// Returns max + 1 to signal "further than we care about".
function boundedEditDistance(aStr, bStr, max) {
if (aStr === bStr) return 0;
const a = [...aStr];
const b = [...bStr];
if (Math.abs(a.length - b.length) > max) return max + 1;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
let prev = new Array(b.length + 1);
let curr = new Array(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
curr[0] = i;
let rowMin = curr[0];
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
if (curr[j] < rowMin) rowMin = curr[j];
}
if (rowMin > max) return max + 1;
const swap = prev; prev = curr; curr = swap;
}
return prev[b.length];
}
// Returns { distance } when `key` should be suppressed, otherwise null.
function duplicateOf(key, prevKey) {
if (!key || !prevKey) return null;
if (key === prevKey) return { distance: 0 };
// Short lines get exact matching only. 「うん」 and 「ううん」 are one edit
// apart and are entirely different lines; a distance rule at that length
// would silently drop real dialogue.
const len = Math.max([...key].length, [...prevKey].length);
if (len < DEDUP_MIN_LENGTH) return null;
const distance = boundedEditDistance(key, prevKey, DEDUP_MAX_EDITS);
if (distance > DEDUP_MAX_EDITS) return null;
if (distance / len > DEDUP_MAX_RATIO) return null;
return { distance };
}
function dispatch(text, lines, tStart) {
const joined = lines.map(l => l.text).join('');
const key = dedupKey(joined);
const tTotal = performance.now() - tStart;
// A line of pure punctuation has an empty key; never let that match.
const dup = duplicateOf(key, lastDispatchedKey);
if (dup) {
log(`Suppressed repeat, ${dup.distance} edit(s) from previous (${tTotal.toFixed(0)}ms): ${joined}`);
ui.setStatus(dup.distance === 0
? 'Same as last line, skipped'
: `Near-duplicate (${dup.distance} char), skipped`);
// lastDispatchedKey deliberately not updated. Comparing against the last
// line actually sent stops a slowly drifting line from being suppressed
// forever, one edit at a time.
return { dispatched: false, suppressed: true, lines };
}
lastDispatchedKey = key;
log(`Dispatching kizuna:add-line (${tTotal.toFixed(0)}ms total): ${joined}`);
window.dispatchEvent(new CustomEvent('kizuna:add-line', { detail: { text: joined } }));
ui.setStatus(`Dispatched ${lines.length} line(s) in ${tTotal.toFixed(0)}ms`);
return { dispatched: true, suppressed: false, lines };
}
// === AUTO LOOP (state machine) ===========================================
const autoController = (function() {
let running = false;
let timer = null;
let lastSample = null;
let stableCount = 0;
let dirty = false;
let lastDispatchTime = 0;
async function tick() {
if (!running) return;
try {
await runTickBody();
} catch (e) {
log('Auto tick error:', e);
}
if (running) timer = setTimeout(tick, POLL_MS);
}
async function runTickBody() {
if (!captureStream || !captureVideo || !scanRegion) {
ui.setStatus('Waiting for region...');
return;
}
if (captureBusy) return;
const regionCanvas = grabRegionCanvas();
if (!regionCanvas) return;
const sample = sampleCanvas(regionCanvas);
const now = performance.now();
if (!lastSample) {
lastSample = sample;
return;
}
const ratio = diffSamples(lastSample, sample);
const changed = ratio >= CHANGE_RATIO;
if (changed) {
lastSample = sample;
stableCount = 0;
dirty = true;
ui.setStatus(`Changing (${(ratio * 100).toFixed(0)}%)...`);
return;
}
if (dirty) {
stableCount++;
ui.setStatus(`Settling ${stableCount}/${STABLE_TICKS}...`);
if (stableCount >= STABLE_TICKS) {
stableCount = 0;
ui.setStatus('Settled, reading');
log('[auto] settled, running OCR');
const result = await capture();
if (result.dispatched || result.suppressed) {
// A suppressed repeat still means we read the screen successfully
// and we're caught up — clear dirty, same as a real dispatch.
// Treating it as a failure would spin the settle loop forever.
dirty = false;
lastDispatchTime = performance.now();
} else {
// OCR ran but found nothing. Take a fresh sample so we don't
// immediately re-trigger on the same frame, but keep dirty=true
// so a real change OR the force-OCR safety net will re-try.
lastSample = sampleCanvas(grabRegionCanvas());
stableCount = 0;
log('[auto] OCR did not dispatch, keeping dirty=true');
}
}
return;
}
// Scene is stable and we believe we're caught up. Force-OCR safety net
// for cases where change detection itself missed (slow subtitle fade-in
// on a near-uniform background, for example).
ui.setStatus('Watching...');
const sinceDispatch = now - lastDispatchTime;
if (lastDispatchTime > 0 && sinceDispatch > FORCE_OCR_INTERVAL_MS) {
log(`[auto] force-OCR safety net (${(sinceDispatch/1000).toFixed(1)}s since last)`);
await capture();
// Reset the timer regardless of outcome so we don't hammer.
lastDispatchTime = performance.now();
}
}
return {
get running() { return running; },
start() {
if (running) return;
running = true;
lastSample = null;
stableCount = 0;
dirty = false;
lastDispatchTime = 0;
log(`Auto started (poll ${POLL_MS}ms, settle ${STABLE_TICKS} ticks, force-OCR ${FORCE_OCR_INTERVAL_MS}ms)`);
tick();
},
stop() {
if (!running) return;
running = false;
clearTimeout(timer);
timer = null;
log('Auto stopped');
},
reset() {
lastSample = null;
stableCount = 0;
dirty = false;
lastDispatchTime = 0;
// New region or manual reset: the same text should be readable again.
lastDispatchedKey = null;
},
};
})();
// === PUBLIC API ===========================================================
window.meikiOcr = {
capture: () => {
const p = capture();
p.then(({ lines }) => {
if (typeof window.onMeikiOcrResult === 'function') window.onMeikiOcrResult(lines);
}).catch(e => ui.setStatus('Error: ' + e.message));
return p;
},
isReady: () => modelsReady,
startAuto: () => autoController.start(),
stopAuto: () => autoController.stop(),
};
window.addEventListener('meiki-ocr-capture', () => capture());
// === INIT =================================================================
try {
await loadModels();
} catch (e) {
ui.setStatus('Failed to load models: ' + e.message);
console.error('[MeikiOCR] Model load error:', e);
}
// === REGION SELECTOR ======================================================
function showRegionSelector(frameCanvas) {
return new Promise((resolve) => {
const dataUrl = frameCanvas.toDataURL('image/jpeg', 0.85);
const vidW = frameCanvas.width;
const vidH = frameCanvas.height;
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
z-index:2147483646;
background:#000 url('${dataUrl}') center/contain no-repeat;
cursor:crosshair;user-select:none;
`;
const vignette = document.createElement('div');
vignette.style.cssText = `position:absolute;inset:0;background:rgba(0,0,0,0.45);pointer-events:none;`;
overlay.appendChild(vignette);
const label = document.createElement('div');
label.textContent = 'Drag to select a scan region. Enter or double-click to confirm, Esc to cancel.';
label.style.cssText = `
position:absolute;top:16px;left:50%;transform:translateX(-50%);
background:#111827;color:#d1d5db;
font-family:system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;font-size:13px;
padding:6px 16px;border-radius:0.25rem;border:1px solid #374151;
pointer-events:none;white-space:nowrap;
`;
overlay.appendChild(label);
const rect = document.createElement('div');
rect.style.cssText = `
position:absolute;border:2px solid #3b82f6;
box-shadow:0 0 0 9999px rgba(0,0,0,0.35);
pointer-events:none;display:none;box-sizing:border-box;
`;
overlay.appendChild(rect);
// Both actions sit in one centered flex row. Positioning them
// individually meant the gap was a center-to-center offset, so the real
// spacing depended on how wide each label happened to render, and the
// pair as a whole sat off-center.
const actionRow = document.createElement('div');
actionRow.style.cssText = `
position:absolute;bottom:16px;left:50%;transform:translateX(-50%);
display:flex;gap:8px;
`;
const btnBase = `
padding:8px 16px;border-radius:0.25rem;cursor:pointer;display:none;
font-family:system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;font-size:13px;
`;
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Confirm region';
confirmBtn.style.cssText = btnBase + `
background:#1f2937;color:#60a5fa;border:1px solid #3b82f6;
`;
actionRow.appendChild(confirmBtn);
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear region';
clearBtn.style.cssText = btnBase + `
background:#1f2937;color:#9ca3af;border:1px solid #374151;
`;
actionRow.appendChild(clearBtn);
overlay.appendChild(actionRow);
document.body.appendChild(overlay);
function getImageRect() {
const vw = window.innerWidth, vh = window.innerHeight;
const scale = Math.min(vw / vidW, vh / vidH);
const dispW = vidW * scale, dispH = vidH * scale;
return { left: (vw - dispW) / 2, top: (vh - dispH) / 2, width: dispW, height: dispH, scale };
}
function toVideoCoords(clientX, clientY) {
const r = getImageRect();
return {
x: Math.round((clientX - r.left) / r.scale),
y: Math.round((clientY - r.top) / r.scale),
};
}
let dragStart = null;
let currentSel = null;
function updateRect(x1, y1, x2, y2) {
const r = getImageRect();
const cx1 = Math.max(r.left, Math.min(r.left + r.width, x1));
const cy1 = Math.max(r.top, Math.min(r.top + r.height, y1));
const cx2 = Math.max(r.left, Math.min(r.left + r.width, x2));
const cy2 = Math.max(r.top, Math.min(r.top + r.height, y2));
const left = Math.min(cx1, cx2);
const top = Math.min(cy1, cy2);
const width = Math.abs(cx2 - cx1);
const height = Math.abs(cy2 - cy1);
rect.style.left = left + 'px';
rect.style.top = top + 'px';
rect.style.width = width + 'px';
rect.style.height = height + 'px';
rect.style.display = width > 4 && height > 4 ? 'block' : 'none';
const vc1 = toVideoCoords(left, top);
const vc2 = toVideoCoords(left + width, top + height);
currentSel = {
x: Math.max(0, vc1.x),
y: Math.max(0, vc1.y),
w: Math.min(vidW, vc2.x) - Math.max(0, vc1.x),
h: Math.min(vidH, vc2.y) - Math.max(0, vc1.y),
};
label.textContent = `${currentSel.w} × ${currentSel.h}px — Enter or double-click to confirm, Esc to cancel`;
}
function swallow(e) { e.stopPropagation(); }
[confirmBtn, clearBtn].forEach(btn => {
btn.addEventListener('mousedown', swallow);
btn.addEventListener('mouseup', swallow);
});
overlay.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (e.target !== overlay && e.target !== vignette) return;
dragStart = { x: e.clientX, y: e.clientY };
confirmBtn.style.display = 'none';
clearBtn.style.display = 'none';
currentSel = null;
});
overlay.addEventListener('mousemove', (e) => {
if (!dragStart) return;
updateRect(dragStart.x, dragStart.y, e.clientX, e.clientY);
});
overlay.addEventListener('mouseup', (e) => {
if (!dragStart) return;
updateRect(dragStart.x, dragStart.y, e.clientX, e.clientY);
dragStart = null;
if (currentSel && currentSel.w > 4 && currentSel.h > 4) {
confirmBtn.style.display = 'block';
clearBtn.style.display = 'block';
}
});
overlay.addEventListener('dblclick', () => {
if (currentSel && currentSel.w > 4) confirm();
});
function cleanup() {
document.removeEventListener('keydown', onKey);
overlay.remove();
}
function confirm() {
scanRegion = currentSel;
autoController.reset();
log('Scan region set:', JSON.stringify(scanRegion));
cleanup();
resolve(scanRegion);
}
function cancel() {
cleanup();
resolve(null);
}
function clearRegion() {
scanRegion = null;
autoController.reset();
rect.style.display = 'none';
confirmBtn.style.display = 'none';
clearBtn.style.display = 'none';
currentSel = null;
label.textContent = 'Region cleared. Drag to set a new one, or press Esc to close.';
log('Scan region cleared');
setTimeout(cleanup, 800);
resolve(null);
}
confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); confirm(); });
clearBtn.addEventListener('click', (e) => { e.stopPropagation(); clearRegion(); });
function onKey(e) {
if (e.key === 'Escape') cancel();
else if (e.key === 'Enter' && currentSel) confirm();
}
document.addEventListener('keydown', onKey);
});
}
// === UI ===================================================================
function createUI() {
const panel = document.createElement('div');
panel.id = 'meiki-ocr-panel';
panel.innerHTML = `
<style>
/* Palette mirrors the Kizuna page: gray-900 surfaces, gray-700
borders, gray-800 buttons, blue-400 accent, red-400 for active. */
#meiki-ocr-panel {
position: fixed; bottom: 16px; right: 16px;
z-index: 2147483647;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
pointer-events: none;
}
#meiki-ocr-panel button {
pointer-events: all;
font-family: inherit; font-size: 13px;
padding: 8px 12px; border-radius: 0.25rem;
background: #1f2937; color: #e5e7eb; border: 1px solid #374151;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
#meiki-ocr-panel button:not(:disabled):hover { background: #374151; }
#meiki-ocr-panel button:disabled { cursor: default; opacity: 0.4; }
#meiki-ocr-panel button:focus-visible {
outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6;
}
#meiki-ocr-status {
background: #111827; color: #9ca3af;
border: 1px solid #374151; padding: 4px 12px; border-radius: 0.25rem;
max-width: 280px; text-align: right; transition: opacity 0.3s;
}
/* Primary action carries the blue accent. */
#meiki-ocr-btn { color: #60a5fa; }
#meiki-ocr-btn:not(:disabled):hover { color: #93c5fd; }
/* Running is a stop action, so it takes the page's destructive style. */
#meiki-ocr-auto.running {
color: #f87171;
background: rgba(127, 29, 29, 0.3);
border-color: rgba(127, 29, 29, 0.5);
}
#meiki-ocr-auto.running:not(:disabled):hover {
background: rgba(127, 29, 29, 0.5);
}
/* Set once a scan region exists. */
#meiki-ocr-region.active { color: #60a5fa; border-color: #3b82f6; }
</style>
<div id="meiki-ocr-status">Initializing...</div>
<div style="display:flex;gap:6px;">
<button id="meiki-ocr-reset" title="Clear region">Reset</button>
<button id="meiki-ocr-region" title="Set scan region">Region</button>
<button id="meiki-ocr-btn" disabled>OCR</button>
<button id="meiki-ocr-auto" disabled title="Start auto OCR">Run</button>
</div>
`;
const attach = () => {
if (document.body) document.body.appendChild(panel);
else document.addEventListener('DOMContentLoaded', () => document.body.appendChild(panel));
};
attach();
const statusEl = () => panel.querySelector('#meiki-ocr-status');
const btnEl = () => panel.querySelector('#meiki-ocr-btn');
const autoBtnEl = () => panel.querySelector('#meiki-ocr-auto');
// Recognized text goes to the kizuna:add-line event and the console. The
// status line reports the count; we deliberately don't mirror the text
// on screen, since it's already added to the page.
async function runOnce() {
try {
await capture();
} catch (e) {
log('runOnce error:', e);
}
}
function refreshAutoBtn() {
const b = autoBtnEl();
if (!b) return;
if (autoController.running) {
b.textContent = 'Stop';
b.classList.add('running');
b.title = 'Stop auto mode';
} else {
b.textContent = 'Run';
b.classList.remove('running');
b.title = 'Start auto mode';
}
}
panel.addEventListener('click', (e) => {
if (e.target.id === 'meiki-ocr-auto' && !e.target.disabled) {
if (autoController.running) autoController.stop();
else autoController.start();
refreshAutoBtn();
}
if (e.target.id === 'meiki-ocr-btn' && !e.target.disabled) {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Running...';
runOnce().then(() => {
if (scanRegion && !autoController.running) {
autoController.start();
refreshAutoBtn();
}
}).finally(() => {
btn.disabled = false;
btn.textContent = 'Capture OCR';
});
}
if (e.target.id === 'meiki-ocr-region') {
if (!captureStream || !captureVideo) {
ui.setStatus('Start a capture first, then set region');
return;
}
const btn = e.target;
scanRegion = null;
const canvas = grabFrame();
showRegionSelector(canvas).then(region => {
if (region) {
btn.classList.add('active');
btn.title = `Scan region: ${region.w}x${region.h} — click to change`;
ui.setStatus(`Region set to ${region.w}×${region.h}, watching`);
if (!autoController.running) { autoController.start(); refreshAutoBtn(); }
} else {
btn.classList.remove('active');
btn.title = 'Set scan region';
ui.setStatus('No region — will prompt on next capture');
}
}).catch(e => log('Region selector error:', e));
}
if (e.target.id === 'meiki-ocr-reset') {
scanRegion = null;
autoController.reset();
const rb = panel.querySelector('#meiki-ocr-region');
if (rb) { rb.classList.remove('active'); rb.title = 'Set scan region'; }
ui.setStatus('Region cleared');
log('Region cleared, state reset');
}
});
let statusTimeout;
return {
setStatus(msg) {
const el = statusEl();
if (el) { el.textContent = msg; el.style.opacity = '1'; }
clearTimeout(statusTimeout);
if (modelsReady) {
statusTimeout = setTimeout(() => { if (el) el.style.opacity = '0.5'; }, 4000);
}
},
setReady(ready) {
const btn = btnEl();
if (btn) {
btn.disabled = !ready;
btn.textContent = ready ? 'Capture OCR' : 'Loading...';
}
const autoBtn = autoBtnEl();
if (autoBtn) autoBtn.disabled = !ready;
},
};
}
// === UTILS ================================================================
function arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
})();
Keep local notes for the visual novel you are reading
// ==UserScript==
// @name Kizuna VN Notepad
// @namespace kizuna-userscripts
// @version 1.0
// @description Keep per-visual-novel notes in the Kizuna texthooker, saved locally
// @match https://kizuna-texthooker-ui.app/*
// @grant GM.getValue
// @grant GM.setValue
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'kizunaVnNotes';
const BTN_ID = 'kizuna-notepad-button';
const PANEL_ID = 'kizuna-notepad-panel';
const SAVE_DEBOUNCE = 400;
const PANEL_WIDTH = 460;
// ---------------------------------------------------------------
// State
// ---------------------------------------------------------------
let state = { version: 1, activeId: null, notes: [] };
let panel = null;
let els = {};
let saveTimer = null;
let loaded = false;
let renaming = false;
const newId = () =>
'n_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
// ---------------------------------------------------------------
// Storage (GM first, localStorage as fallback)
// ---------------------------------------------------------------
const hasGM = typeof GM !== 'undefined' && GM && typeof GM.getValue === 'function';
const store = {
async read() {
try {
const raw = hasGM
? await GM.getValue(STORAGE_KEY, null)
: localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
console.warn('[Notepad] Could not read notes:', e);
return null;
}
},
async write(value) {
const raw = JSON.stringify(value);
try {
if (hasGM) await GM.setValue(STORAGE_KEY, raw);
else localStorage.setItem(STORAGE_KEY, raw);
return true;
} catch (e) {
console.warn('[Notepad] Could not save notes:', e);
return false;
}
}
};
async function loadState() {
const saved = await store.read();
if (saved && Array.isArray(saved.notes)) state = saved;
loaded = true;
}
function scheduleSave() {
setStatus('Saving…');
clearTimeout(saveTimer);
saveTimer = setTimeout(flushSave, SAVE_DEBOUNCE);
}
async function flushSave() {
clearTimeout(saveTimer);
saveTimer = null;
const ok = await store.write(state);
setStatus(ok ? 'Saved ' + timeLabel(Date.now()) : 'Not saved — storage unavailable');
}
// ---------------------------------------------------------------
// Note helpers
// ---------------------------------------------------------------
const activeNote = () => state.notes.find((n) => n.id === state.activeId) || null;
function createNote(title) {
const note = {
id: newId(),
title: title || 'Untitled note',
body: '',
createdAt: Date.now(),
updatedAt: Date.now()
};
state.notes.push(note);
state.activeId = note.id;
scheduleSave();
return note;
}
function deleteActive() {
const note = activeNote();
if (!note) return;
state.notes = state.notes.filter((n) => n.id !== note.id);
state.activeId = state.notes.length ? state.notes[0].id : null;
scheduleSave();
}
function timeLabel(ts) {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ---------------------------------------------------------------
// Rename
//
// The title lives in the dropdown only. Renaming swaps an input over
// that same slot so the title is never displayed in two places.
// ---------------------------------------------------------------
function startRename() {
const note = activeNote();
if (!note) return;
renaming = true;
els.title.value = note.title;
els.select.style.visibility = 'hidden';
els.caret.style.display = 'none';
els.title.style.display = 'block';
els.rename.innerHTML = CHECK_ICON;
els.rename.title = 'Save name';
els.title.focus();
els.title.select();
}
function endRename() {
renaming = false;
els.title.style.display = 'none';
els.select.style.visibility = '';
els.caret.style.display = '';
els.rename.innerHTML = PENCIL_ICON;
els.rename.title = 'Rename note';
}
function commitRename() {
const note = activeNote();
if (!note) return endRename();
const next = els.title.value.trim();
if (next && next !== note.title) {
note.title = next;
note.updatedAt = Date.now();
renderSelect();
scheduleSave();
}
endRename();
if (isOpen()) els.body.focus();
}
function cancelRename() {
endRename();
if (isOpen()) els.body.focus();
}
// ---------------------------------------------------------------
// Key isolation
//
// Kizuna binds single-key shortcuts (pause, delete line, …) and
// phx-window-keydown on window. Registering a capture listener at
// document-start puts us ahead of app.js, so keystrokes typed inside
// the notepad never reach the page. The default action still runs,
// so typing works normally.
// ---------------------------------------------------------------
function insidePanel(target) {
return panel && target instanceof Node && panel.contains(target);
}
['keydown', 'keyup', 'keypress', 'paste'].forEach((type) => {
window.addEventListener(
type,
(e) => {
if (!insidePanel(e.target)) return;
if (type === 'keydown') {
// While renaming, Enter commits and Escape reverts. Otherwise
// Escape closes the panel.
if (renaming && e.key === 'Enter') { e.preventDefault(); commitRename(); }
else if (renaming && e.key === 'Escape') { e.preventDefault(); cancelRename(); }
else if (e.key === 'Escape') closePanel();
}
e.stopImmediatePropagation();
},
true
);
});
// ---------------------------------------------------------------
// Panel
// ---------------------------------------------------------------
function buildPanel() {
panel = document.createElement('div');
panel.id = PANEL_ID;
// Lives outside the LiveView root so DOM patches never touch it.
panel.className = 'bg-gray-900 border border-gray-700 p-4 flex flex-col gap-3';
panel.style.cssText =
'position:fixed; z-index:9998; display:none; max-height:80vh; overflow-y:auto; color:#e0e0e0;';
panel.innerHTML = `
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Notes</h3>
<button type="button" data-role="close" class="cursor-pointer text-gray-400 hover:text-gray-200">✕</button>
</div>
<div class="flex items-stretch gap-2">
<div class="relative flex-1 min-w-0">
<select data-role="select"
class="w-full h-full pl-3 pr-10 py-2 rounded border border-gray-700 bg-gray-900 text-gray-300 text-sm focus:outline-none focus:border-gray-600 cursor-pointer appearance-none"></select>
<div data-role="caret" class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<div class="w-3 h-2 bg-gray-400" style="clip-path: polygon(100% 0%, 0 0%, 50% 100%);"></div>
</div>
<input type="text" data-role="title" maxlength="80" placeholder="Note title"
class="absolute inset-0 w-full px-3 py-2 rounded border border-gray-700 bg-gray-900 text-gray-100 text-sm focus:outline-none focus:border-gray-600"
style="display:none;">
</div>
<button type="button" data-role="rename" title="Rename note"
class="flex-shrink-0 flex items-center justify-center px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white rounded border border-gray-700 transition-colors"></button>
<button type="button" data-role="new"
class="flex-shrink-0 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded border border-gray-700 transition-colors">New</button>
<button type="button" data-role="delete"
class="flex-shrink-0 px-3 py-2 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm rounded border border-red-900/50 transition-colors">Delete</button>
</div>
<div data-role="editor" class="flex flex-col gap-2">
<textarea data-role="body" rows="14" spellcheck="false" placeholder="Vocab, plot notes, character names…"
class="w-full bg-gray-900 text-gray-100 rounded px-3 py-2 text-sm border border-gray-700 focus:outline-none focus:border-gray-600"
style="resize:vertical; min-height:280px; line-height:1.6;"></textarea>
</div>
<div data-role="empty" class="text-center py-10 text-gray-400 text-sm" style="display:none;">
No notes yet. Create one to start writing.
</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span data-role="status"></span>
<span data-role="count"></span>
</div>
`;
document.body.appendChild(panel);
const q = (role) => panel.querySelector(`[data-role="${role}"]`);
els = {
close: q('close'),
select: q('select'),
caret: q('caret'),
rename: q('rename'),
new: q('new'),
delete: q('delete'),
editor: q('editor'),
title: q('title'),
body: q('body'),
empty: q('empty'),
status: q('status'),
count: q('count')
};
els.rename.innerHTML = PENCIL_ICON;
els.close.addEventListener('click', closePanel);
els.select.addEventListener('change', () => {
if (renaming) endRename();
flushSave();
state.activeId = els.select.value || null;
renderEditor();
scheduleSave();
els.body.focus();
});
els.new.addEventListener('click', () => {
createNote();
renderAll();
startRename();
});
els.delete.addEventListener('click', () => {
const note = activeNote();
if (!note) return;
if (!confirm(`Delete "${note.title}"? This cannot be undone.`)) return;
deleteActive();
renderAll();
});
// mousedown + preventDefault keeps the input from blurring first, which
// would commit the rename and leave the click to reopen it.
els.rename.addEventListener('mousedown', (e) => {
e.preventDefault();
renaming ? commitRename() : startRename();
});
els.body.addEventListener('input', () => {
const note = activeNote();
if (!note) return;
note.body = els.body.value;
note.updatedAt = Date.now();
renderCount();
scheduleSave();
});
els.body.addEventListener('blur', flushSave);
els.title.addEventListener('blur', () => { if (renaming) commitRename(); });
}
function renderSelect() {
const sorted = [...state.notes].sort((a, b) => b.updatedAt - a.updatedAt);
els.select.innerHTML = '';
for (const note of sorted) {
const opt = document.createElement('option');
opt.value = note.id;
opt.textContent = note.title || 'Untitled note';
if (note.id === state.activeId) opt.selected = true;
els.select.appendChild(opt);
}
els.select.disabled = sorted.length === 0;
}
function renderEditor() {
const note = activeNote();
const has = !!note;
els.editor.style.display = has ? 'flex' : 'none';
els.empty.style.display = has ? 'none' : 'block';
els.delete.disabled = !has;
els.delete.style.opacity = has ? '1' : '0.4';
els.rename.disabled = !has;
els.rename.style.opacity = has ? '1' : '0.4';
if (!has) {
els.count.textContent = '';
return;
}
els.body.value = note.body;
renderCount();
}
function renderCount() {
const note = activeNote();
els.count.textContent = note ? `${note.body.length.toLocaleString()} characters` : '';
}
function setStatus(text) {
if (els.status) els.status.textContent = text;
}
function renderAll() {
renderSelect();
renderEditor();
}
// ---------------------------------------------------------------
// Open / close / position
// ---------------------------------------------------------------
function positionPanel() {
const btn = document.getElementById(BTN_ID);
if (!btn || !panel) return;
const r = btn.getBoundingClientRect();
const width = Math.min(PANEL_WIDTH, window.innerWidth - 16);
const left = Math.max(8, Math.min(r.right - width, window.innerWidth - width - 8));
panel.style.width = width + 'px';
panel.style.left = left + 'px';
panel.style.top = r.bottom + 8 + 'px';
}
const isOpen = () => panel && panel.style.display === 'flex';
async function openPanel() {
if (!loaded) await loadState();
if (!state.notes.length) createNote();
renderAll();
setStatus('');
panel.style.display = 'flex';
positionPanel();
window.addEventListener('resize', positionPanel);
window.addEventListener('scroll', positionPanel, true);
document.addEventListener('click', onDocumentClick, true);
els.body.focus();
}
function closePanel() {
if (!panel) return;
// Hide first so commitRename's focus restore is skipped.
panel.style.display = 'none';
if (renaming) commitRename();
flushSave();
window.removeEventListener('resize', positionPanel);
window.removeEventListener('scroll', positionPanel, true);
document.removeEventListener('click', onDocumentClick, true);
}
function togglePanel() {
isOpen() ? closePanel() : openPanel();
}
function onDocumentClick(e) {
if (!isOpen()) return;
const btn = document.getElementById(BTN_ID);
if (insidePanel(e.target)) return;
if (btn && btn.contains(e.target)) return;
closePanel();
}
// ---------------------------------------------------------------
// Toolbar button
//
// The toolbar lives inside the LiveView root, so a DOM patch can drop
// anything we inject. A MutationObserver puts it back.
// ---------------------------------------------------------------
// display:block keeps the icon off the text baseline, so it lines up with
// the mask-based hero-* spans in the rest of the toolbar.
const ICON = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="mt-1.5 w-5 h-5" style="display:block;">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>`;
// Pencil / check for the rename toggle, sized to sit level with the
// adjacent text buttons.
const PENCIL_ICON = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-5 h-5" style="display:block;">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
</svg>`;
const CHECK_ICON = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="w-5 h-5" style="display:block;">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>`;
function injectButton() {
if (document.getElementById(BTN_ID)) return;
// Anchor off a stable id rather than class soup.
const anchor = document.getElementById('websocket-toggle-btn');
const bar = anchor && anchor.parentElement;
if (!bar) return;
const btn = document.createElement('button');
btn.id = BTN_ID;
btn.type = 'button';
btn.title = 'Notes';
btn.className = 'cursor-pointer flex items-center hover:text-blue-300';
btn.innerHTML = ICON;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
togglePanel();
});
const settings = bar.querySelector('#settings-wrapper');
if (settings) bar.insertBefore(btn, settings);
else bar.appendChild(btn);
}
function watchToolbar() {
let queued = false;
const observer = new MutationObserver(() => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
injectButton();
if (isOpen()) {
if (document.getElementById(BTN_ID)) positionPanel();
else closePanel();
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ---------------------------------------------------------------
// Boot
// ---------------------------------------------------------------
async function init() {
await loadState();
buildPanel();
injectButton();
watchToolbar();
window.addEventListener('beforeunload', () => {
if (saveTimer) store.write(state);
});
console.log('[Notepad] Ready.');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
// ==UserScript==
// @name Kizuna VN Notepad
// @namespace kizuna-userscripts
// @version 1.0
// @description Keep per-visual-novel notes in the Kizuna texthooker, saved locally
// @match https://kizuna-texthooker-ui.app/*
// @grant GM.getValue
// @grant GM.setValue
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'kizunaVnNotes';
const BTN_ID = 'kizuna-notepad-button';
const PANEL_ID = 'kizuna-notepad-panel';
const SAVE_DEBOUNCE = 400;
const PANEL_WIDTH = 460;
// ---------------------------------------------------------------
// State
// ---------------------------------------------------------------
let state = { version: 1, activeId: null, notes: [] };
let panel = null;
let els = {};
let saveTimer = null;
let loaded = false;
let renaming = false;
const newId = () =>
'n_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
// ---------------------------------------------------------------
// Storage (GM first, localStorage as fallback)
// ---------------------------------------------------------------
const hasGM = typeof GM !== 'undefined' && GM && typeof GM.getValue === 'function';
const store = {
async read() {
try {
const raw = hasGM
? await GM.getValue(STORAGE_KEY, null)
: localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
console.warn('[Notepad] Could not read notes:', e);
return null;
}
},
async write(value) {
const raw = JSON.stringify(value);
try {
if (hasGM) await GM.setValue(STORAGE_KEY, raw);
else localStorage.setItem(STORAGE_KEY, raw);
return true;
} catch (e) {
console.warn('[Notepad] Could not save notes:', e);
return false;
}
}
};
async function loadState() {
const saved = await store.read();
if (saved && Array.isArray(saved.notes)) state = saved;
loaded = true;
}
function scheduleSave() {
setStatus('Saving…');
clearTimeout(saveTimer);
saveTimer = setTimeout(flushSave, SAVE_DEBOUNCE);
}
async function flushSave() {
clearTimeout(saveTimer);
saveTimer = null;
const ok = await store.write(state);
setStatus(ok ? 'Saved ' + timeLabel(Date.now()) : 'Not saved — storage unavailable');
}
// ---------------------------------------------------------------
// Note helpers
// ---------------------------------------------------------------
const activeNote = () => state.notes.find((n) => n.id === state.activeId) || null;
function createNote(title) {
const note = {
id: newId(),
title: title || 'Untitled note',
body: '',
createdAt: Date.now(),
updatedAt: Date.now()
};
state.notes.push(note);
state.activeId = note.id;
scheduleSave();
return note;
}
function deleteActive() {
const note = activeNote();
if (!note) return;
state.notes = state.notes.filter((n) => n.id !== note.id);
state.activeId = state.notes.length ? state.notes[0].id : null;
scheduleSave();
}
function timeLabel(ts) {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ---------------------------------------------------------------
// Rename
//
// The title lives in the dropdown only. Renaming swaps an input over
// that same slot so the title is never displayed in two places.
// ---------------------------------------------------------------
function startRename() {
const note = activeNote();
if (!note) return;
renaming = true;
els.title.value = note.title;
els.select.style.visibility = 'hidden';
els.caret.style.display = 'none';
els.title.style.display = 'block';
els.rename.innerHTML = CHECK_ICON;
els.rename.title = 'Save name';
els.title.focus();
els.title.select();
}
function endRename() {
renaming = false;
els.title.style.display = 'none';
els.select.style.visibility = '';
els.caret.style.display = '';
els.rename.innerHTML = PENCIL_ICON;
els.rename.title = 'Rename note';
}
function commitRename() {
const note = activeNote();
if (!note) return endRename();
const next = els.title.value.trim();
if (next && next !== note.title) {
note.title = next;
note.updatedAt = Date.now();
renderSelect();
scheduleSave();
}
endRename();
if (isOpen()) els.body.focus();
}
function cancelRename() {
endRename();
if (isOpen()) els.body.focus();
}
// ---------------------------------------------------------------
// Key isolation
//
// Kizuna binds single-key shortcuts (pause, delete line, …) and
// phx-window-keydown on window. Registering a capture listener at
// document-start puts us ahead of app.js, so keystrokes typed inside
// the notepad never reach the page. The default action still runs,
// so typing works normally.
// ---------------------------------------------------------------
function insidePanel(target) {
return panel && target instanceof Node && panel.contains(target);
}
['keydown', 'keyup', 'keypress', 'paste'].forEach((type) => {
window.addEventListener(
type,
(e) => {
if (!insidePanel(e.target)) return;
if (type === 'keydown') {
// While renaming, Enter commits and Escape reverts. Otherwise
// Escape closes the panel.
if (renaming && e.key === 'Enter') { e.preventDefault(); commitRename(); }
else if (renaming && e.key === 'Escape') { e.preventDefault(); cancelRename(); }
else if (e.key === 'Escape') closePanel();
}
e.stopImmediatePropagation();
},
true
);
});
// ---------------------------------------------------------------
// Panel
// ---------------------------------------------------------------
function buildPanel() {
panel = document.createElement('div');
panel.id = PANEL_ID;
// Lives outside the LiveView root so DOM patches never touch it.
panel.className = 'bg-gray-900 border border-gray-700 p-4 flex flex-col gap-3';
panel.style.cssText =
'position:fixed; z-index:9998; display:none; max-height:80vh; overflow-y:auto; color:#e0e0e0;';
panel.innerHTML = `
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Notes</h3>
<button type="button" data-role="close" class="cursor-pointer text-gray-400 hover:text-gray-200">✕</button>
</div>
<div class="flex items-stretch gap-2">
<div class="relative flex-1 min-w-0">
<select data-role="select"
class="w-full h-full pl-3 pr-10 py-2 rounded border border-gray-700 bg-gray-900 text-gray-300 text-sm focus:outline-none focus:border-gray-600 cursor-pointer appearance-none"></select>
<div data-role="caret" class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<div class="w-3 h-2 bg-gray-400" style="clip-path: polygon(100% 0%, 0 0%, 50% 100%);"></div>
</div>
<input type="text" data-role="title" maxlength="80" placeholder="Note title"
class="absolute inset-0 w-full px-3 py-2 rounded border border-gray-700 bg-gray-900 text-gray-100 text-sm focus:outline-none focus:border-gray-600"
style="display:none;">
</div>
<button type="button" data-role="rename" title="Rename note"
class="flex-shrink-0 flex items-center justify-center px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white rounded border border-gray-700 transition-colors"></button>
<button type="button" data-role="new"
class="flex-shrink-0 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded border border-gray-700 transition-colors">New</button>
<button type="button" data-role="delete"
class="flex-shrink-0 px-3 py-2 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm rounded border border-red-900/50 transition-colors">Delete</button>
</div>
<div data-role="editor" class="flex flex-col gap-2">
<textarea data-role="body" rows="14" spellcheck="false" placeholder="Vocab, plot notes, character names…"
class="w-full bg-gray-900 text-gray-100 rounded px-3 py-2 text-sm border border-gray-700 focus:outline-none focus:border-gray-600"
style="resize:vertical; min-height:280px; line-height:1.6;"></textarea>
</div>
<div data-role="empty" class="text-center py-10 text-gray-400 text-sm" style="display:none;">
No notes yet. Create one to start writing.
</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span data-role="status"></span>
<span data-role="count"></span>
</div>
`;
document.body.appendChild(panel);
const q = (role) => panel.querySelector(`[data-role="${role}"]`);
els = {
close: q('close'),
select: q('select'),
caret: q('caret'),
rename: q('rename'),
new: q('new'),
delete: q('delete'),
editor: q('editor'),
title: q('title'),
body: q('body'),
empty: q('empty'),
status: q('status'),
count: q('count')
};
els.rename.innerHTML = PENCIL_ICON;
els.close.addEventListener('click', closePanel);
els.select.addEventListener('change', () => {
if (renaming) endRename();
flushSave();
state.activeId = els.select.value || null;
renderEditor();
scheduleSave();
els.body.focus();
});
els.new.addEventListener('click', () => {
createNote();
renderAll();
startRename();
});
els.delete.addEventListener('click', () => {
const note = activeNote();
if (!note) return;
if (!confirm(`Delete "${note.title}"? This cannot be undone.`)) return;
deleteActive();
renderAll();
});
// mousedown + preventDefault keeps the input from blurring first, which
// would commit the rename and leave the click to reopen it.
els.rename.addEventListener('mousedown', (e) => {
e.preventDefault();
renaming ? commitRename() : startRename();
});
els.body.addEventListener('input', () => {
const note = activeNote();
if (!note) return;
note.body = els.body.value;
note.updatedAt = Date.now();
renderCount();
scheduleSave();
});
els.body.addEventListener('blur', flushSave);
els.title.addEventListener('blur', () => { if (renaming) commitRename(); });
}
function renderSelect() {
const sorted = [...state.notes].sort((a, b) => b.updatedAt - a.updatedAt);
els.select.innerHTML = '';
for (const note of sorted) {
const opt = document.createElement('option');
opt.value = note.id;
opt.textContent = note.title || 'Untitled note';
if (note.id === state.activeId) opt.selected = true;
els.select.appendChild(opt);
}
els.select.disabled = sorted.length === 0;
}
function renderEditor() {
const note = activeNote();
const has = !!note;
els.editor.style.display = has ? 'flex' : 'none';
els.empty.style.display = has ? 'none' : 'block';
els.delete.disabled = !has;
els.delete.style.opacity = has ? '1' : '0.4';
els.rename.disabled = !has;
els.rename.style.opacity = has ? '1' : '0.4';
if (!has) {
els.count.textContent = '';
return;
}
els.body.value = note.body;
renderCount();
}
function renderCount() {
const note = activeNote();
els.count.textContent = note ? `${note.body.length.toLocaleString()} characters` : '';
}
function setStatus(text) {
if (els.status) els.status.textContent = text;
}
function renderAll() {
renderSelect();
renderEditor();
}
// ---------------------------------------------------------------
// Open / close / position
// ---------------------------------------------------------------
function positionPanel() {
const btn = document.getElementById(BTN_ID);
if (!btn || !panel) return;
const r = btn.getBoundingClientRect();
const width = Math.min(PANEL_WIDTH, window.innerWidth - 16);
const left = Math.max(8, Math.min(r.right - width, window.innerWidth - width - 8));
panel.style.width = width + 'px';
panel.style.left = left + 'px';
panel.style.top = r.bottom + 8 + 'px';
}
const isOpen = () => panel && panel.style.display === 'flex';
async function openPanel() {
if (!loaded) await loadState();
if (!state.notes.length) createNote();
renderAll();
setStatus('');
panel.style.display = 'flex';
positionPanel();
window.addEventListener('resize', positionPanel);
window.addEventListener('scroll', positionPanel, true);
document.addEventListener('click', onDocumentClick, true);
els.body.focus();
}
function closePanel() {
if (!panel) return;
// Hide first so commitRename's focus restore is skipped.
panel.style.display = 'none';
if (renaming) commitRename();
flushSave();
window.removeEventListener('resize', positionPanel);
window.removeEventListener('scroll', positionPanel, true);
document.removeEventListener('click', onDocumentClick, true);
}
function togglePanel() {
isOpen() ? closePanel() : openPanel();
}
function onDocumentClick(e) {
if (!isOpen()) return;
const btn = document.getElementById(BTN_ID);
if (insidePanel(e.target)) return;
if (btn && btn.contains(e.target)) return;
closePanel();
}
// ---------------------------------------------------------------
// Toolbar button
//
// The toolbar lives inside the LiveView root, so a DOM patch can drop
// anything we inject. A MutationObserver puts it back.
// ---------------------------------------------------------------
// display:block keeps the icon off the text baseline, so it lines up with
// the mask-based hero-* spans in the rest of the toolbar.
const ICON = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="mt-1.5 w-5 h-5" style="display:block;">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>`;
// Pencil / check for the rename toggle, sized to sit level with the
// adjacent text buttons.
const PENCIL_ICON = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-5 h-5" style="display:block;">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
</svg>`;
const CHECK_ICON = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="w-5 h-5" style="display:block;">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>`;
function injectButton() {
if (document.getElementById(BTN_ID)) return;
// Anchor off a stable id rather than class soup.
const anchor = document.getElementById('websocket-toggle-btn');
const bar = anchor && anchor.parentElement;
if (!bar) return;
const btn = document.createElement('button');
btn.id = BTN_ID;
btn.type = 'button';
btn.title = 'Notes';
btn.className = 'cursor-pointer flex items-center hover:text-blue-300';
btn.innerHTML = ICON;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
togglePanel();
});
const settings = bar.querySelector('#settings-wrapper');
if (settings) bar.insertBefore(btn, settings);
else bar.appendChild(btn);
}
function watchToolbar() {
let queued = false;
const observer = new MutationObserver(() => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
injectButton();
if (isOpen()) {
if (document.getElementById(BTN_ID)) positionPanel();
else closePanel();
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ---------------------------------------------------------------
// Boot
// ---------------------------------------------------------------
async function init() {
await loadState();
buildPanel();
injectButton();
watchToolbar();
window.addEventListener('beforeunload', () => {
if (saveTimer) store.write(state);
});
console.log('[Notepad] Ready.');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
September 5, 2026
August 14, 2026
August 10, 2026
June 22, 2026
May 16, 2026
May 15, 2025
April 18, 2026
March 15, 2026
February 13, 2026
January 19, 2026
January 4, 2026
January 2, 2026
January 1, 2026
December 29, 2025
December 26, 2025
December 24, 2025
December 22, 2025
December 21, 2025
December 18, 2025
December 14, 2025
December 12, 2025
December 9, 2025
December 8, 2025
December 4, 2025
December 2, 2025
December 1, 2025
Kizuna is designed to work without handling any personal information.
Here are some notes about how it works: