feat: add extension implementation and docs

Add manifest.json, sidepanel components, and scripts.
Include project assets and documentation files.
Remove placeholder blank file.
This commit is contained in:
Kunthawat Greethong
2026-01-06 08:49:28 +07:00
parent 87dd2931fa
commit f490c63632
23 changed files with 4586 additions and 0 deletions

117
scripts/background.js Normal file
View File

@@ -0,0 +1,117 @@
// Background service worker for Auto Cover Generator
// 1. Native Click Behavior
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
const openTabs = new Set();
// 2. Click Handler (Native behavior handles opening, we just manage content)
// Note: chrome.action.onClicked is ignored when openPanelOnActionClick is true.
// 3. Tab State Management
// Monitor all tab changes to ensure correct panel is shown
chrome.runtime.onInstalled.addListener(() => {
initializeAllTabs();
});
chrome.runtime.onStartup.addListener(() => {
initializeAllTabs();
});
async function initializeAllTabs() {
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
await checkAndSetSidePanel(tab.id);
}
}
// 4. Tab Activation / Isolation Logic
chrome.tabs.onActivated.addListener(async (activeInfo) => {
const tabId = activeInfo.tabId;
await checkAndSetSidePanel(tabId);
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' || changeInfo.url) {
await checkAndSetSidePanel(tabId);
}
});
async function checkAndSetSidePanel(tabId) {
try {
const tab = await chrome.tabs.get(tabId);
if (!tab) return; // Tab doesn't exist
// If we don't have permission to see the URL, it's definitely not Gemini (since we have host_permissions for Gemini).
// So undefined tab.url means "Restricted/Other Site".
const isGemini = tab.url && tab.url.startsWith('https://gemini.google.com/');
if (isGemini) {
// Enable and set path for Gemini
await chrome.sidePanel.setOptions({
tabId: tabId,
path: 'sidepanel/panel.html',
enabled: true
});
} else {
// Disable for all other sites - this CLOSES the panel if open
// and ensures it stays closed when returning to the tab (until clicked manually)
await chrome.sidePanel.setOptions({
tabId: tabId,
enabled: false
});
}
} catch (e) {
// Tab might be closed or we heavily failed access
console.log('Cannot access tab or set panel', e);
}
}
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setOptions({ enabled: false });
});
chrome.tabs.onRemoved.addListener((tabId) => {
openTabs.delete(tabId);
});
// 4. Message Routing
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Forward messages to content script
if (message.action === 'generateCover' || message.action === 'removeObjectFromImage') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, message, (response) => {
sendResponse(response);
});
}
});
return true;
}
if (message.action === 'downloadImage') {
const url = message.url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `gemini_gen_${timestamp}.png`;
chrome.downloads.download({
url: url,
filename: filename,
conflictAction: 'uniquify'
}, (downloadId) => {
if (chrome.runtime.lastError) {
console.error('Download failed:', chrome.runtime.lastError);
} else {
console.log('Download started, ID:', downloadId);
}
});
return;
}
if (message.action === 'generationError') {
chrome.runtime.sendMessage(message).catch(() => { });
}
});
console.log('Auto Cover Generator: Background Service Ready');

662
scripts/content.js Normal file
View File

@@ -0,0 +1,662 @@
// Content script for Gemini integration
console.log('Auto Cover Generator content script loaded on Gemini');
// Selectors for Gemini UI elements (may need adjustment based on Gemini's actual DOM)
const SELECTORS = {
inputArea: 'rich-textarea[placeholder*="Enter"], textarea[placeholder*="Enter"], div[contenteditable="true"]',
sendButton: 'button[aria-label*="Send"], button[type="submit"]',
imageUploadButton: 'button[aria-label*="Add"], input[type="file"]',
generatedImage: 'img[src*="googleusercontent"], img[src*="generated"], img.image.loaded, img[alt="รูปภาพ"]',
responseContainer: 'div[data-message-author-role="model"], .model-response'
};
let isGenerating = false;
let currentPrompt = null;
let currentImage = null;
let lastGenerationRequestTime = 0; // Timestamp of last extension-triggered generation
// Listen for messages from side panel
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'generateCover' || message.action === 'removeObjectFromImage') {
// Reuse the generation handler but adapt inputs
const isRemover = message.action === 'removeObjectFromImage';
// For remover, the 'image' field is actually the 'imageData' (base64)
// and we wrap it in an array to match logic.
// For cover, 'images' or 'image' is used.
const msgAdapter = isRemover ? {
prompt: message.prompt,
images: [message.imageData] // Specific for remover
} : message;
handleGenerateCover(msgAdapter)
.then(() => sendResponse({ success: true }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true; // Keep channel open for async response
}
});
// Main generation handler
async function handleGenerateCover(message) {
if (isGenerating) {
throw new Error('กำลังสร้างอยู่แล้ว กรุณารอสักครู่');
}
isGenerating = true;
lastGenerationRequestTime = Date.now(); // Start the "Auto Download" window
currentPrompt = message.prompt;
currentImage = message.image;
try {
// Step 1: Find input area
const inputElement = await waitForElement(SELECTORS.inputArea, 5000);
if (!inputElement) {
throw new Error('ไม่พบช่องกรอกข้อความของ Gemini');
}
// Count existing images to differentiate new generation
const existingImages = document.querySelectorAll(SELECTORS.generatedImage);
const initialImageCount = existingImages.length;
console.log(`Initial image count: ${initialImageCount}`);
// Step 2: Upload images if provided
const images = message.images || (message.image ? [message.image] : []);
if (images.length > 0) {
await uploadImagesToGemini(images, inputElement);
// Wait for image upload processing
// User requested delay consistent with number of attached images
// Optimized: Reduced from 3000ms to 1000ms per image for speed
const uploadDelay = Math.min(images.length * 1000, 12000); // Max 12s
console.log(`Waiting ${uploadDelay}ms for ${images.length} images to upload/process...`);
await sleep(uploadDelay);
}
// Step 3: Insert prompt
await insertPrompt(inputElement, currentPrompt);
await sleep(100); // Reduced from 500
// Step 3.5: Press Enter key
await pressEnter(inputElement);
await sleep(200); // Reduced from 500
// Step 4: Click send button with retry
const sent = await clickSendButton();
if (sent) {
// Wait a bit for Gemini to process the prompt
await sleep(1000); // Reduced from 2000
// Step 4.5: Auto-click generate/create button if it appears
await autoClickGenerateButton();
// Step 5: Wait for and capture generated image
// Network Listener in background.js now handles detection and download!
// await waitForGeneratedImage(initialImageCount);
console.log('Content: Waiting for background network detection...');
} else {
throw new Error('ไม่สามารถกดปุ่มส่งข้อความได้');
}
} catch (error) {
console.error('Generation error:', error);
chrome.runtime.sendMessage({
action: 'generationError',
error: error.message
});
} finally {
isGenerating = false;
}
}
// Click send button with retry
async function clickSendButton() {
const maxRetries = 10;
for (let i = 0; i < maxRetries; i++) {
const sendButton = document.querySelector(SELECTORS.sendButton);
if (sendButton && !sendButton.disabled && sendButton.getAttribute('aria-disabled') !== 'true') {
console.log('Found enabled send button, clicking...');
sendButton.click();
return true;
}
console.log('Waiting for send button to be enabled...');
await sleep(500);
}
return false;
}
// Simulate Enter key press
async function pressEnter(element) {
console.log('Simulating Enter key...');
const events = ['keydown', 'keypress', 'keyup'];
for (const type of events) {
const event = new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
char: '\r',
view: window
});
element.dispatchEvent(event);
await sleep(50);
}
}
// Insert prompt into Gemini input
async function insertPrompt(element, prompt) {
element.focus();
// Clear existing content if possible
if (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT') {
element.value = '';
element.value = prompt;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// ContentEditable div
element.textContent = prompt;
// Dispatch complex event sequence for rich text editors
const events = ['keydown', 'keypress', 'textInput', 'input', 'keyup'];
events.forEach(eventType => {
const event = new Event(eventType, { bubbles: true, cancelable: true });
element.dispatchEvent(event);
});
// Trigger specific InputEvent for modern frameworks
try {
const inputEvent = new InputEvent('input', {
bubbles: true,
cancelable: true,
inputType: 'insertText',
data: prompt
});
element.dispatchEvent(inputEvent);
} catch (e) {
// Fallback for older browsers
}
}
// Small delay to let framework validation run
await sleep(200);
element.blur();
await sleep(100);
element.focus();
}
// Super Robust Upload Function: Tries Paste -> DragDrop -> Classical Input
async function uploadImagesToGemini(imagesDatBase64, inputElement) {
console.log('Starting hybrid upload sequence...');
// Helper: Base64 to Blob
const base64ToBlob = (base64) => {
const arr = base64.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([new Uint8Array(u8arr)], 'image.png', { type: mime });
};
// Prepare files
const dataTransfer = new DataTransfer();
for (let i = 0; i < imagesDatBase64.length; i++) {
const file = base64ToBlob(imagesDatBase64[i]);
dataTransfer.items.add(file);
}
const fileList = dataTransfer.files;
// STRATEGY 1: DISPATCH PASTE EVENT
try {
console.log('Strategy 1: Simulating Paste Event...');
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer
});
// Target the specific input (textarea/div) first
inputElement.dispatchEvent(pasteEvent);
await sleep(500);
} catch (e) {
console.warn('Strategy 1 failed', e);
}
// STRATEGY 2: CLICK ADD BUTTON & ATTACH TO INPUT (Fall back to native interaction)
console.log('Strategy 2: UI Interaction (Find & Click Add Button)...');
// 2.1 Find the Add Button
// Scoped search near input
let addBtn = null;
let inputContainer = document.body;
if (inputElement) {
inputContainer = inputElement.closest('.textarea-wrapper, .input-area, .text-input-field, footer, .main-footer') ||
inputElement.parentElement.parentElement;
}
if (inputContainer) {
// Updated selectors based on common Gemini UI and Material Design
const selectors = [
'button[aria-label*="Upload"]',
'button[aria-label*="Add"]',
'button[aria-label*="เลือกรูป"]',
'button[aria-label*="เพิ่ม"]',
'.mat-mdc-button-touch-target', // Material touch target often overlays the icon
'button:has(mat-icon[data-mat-icon-name="add"])',
'button:has(svg)', // Risky but constrained to inputContainer
];
for (const sel of selectors) {
const found = inputContainer.querySelectorAll(sel);
for (const btn of found) {
// Check if it looks like the plus button (usually first button on left)
const rect = btn.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
addBtn = btn;
break;
}
}
if (addBtn) break;
}
}
if (addBtn) {
console.log('Found Add Button, clicking...', addBtn);
addBtn.click();
await sleep(800); // Wait for menu animation
} else {
console.log('Could not pinpoint Add Button, skipping click step.');
}
// 2.2 Find File Input (It should exist now if click worked, or be always present)
const fileInput = document.querySelector('input[type="file"]');
if (fileInput) {
console.log('Found File Input, attaching files...');
fileInput.files = fileList;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
fileInput.dispatchEvent(new Event('input', { bubbles: true }));
console.log('Files attached to input.');
return true;
} else {
console.warn('Strategy 2 failed: No file input found even after clicking.');
}
// STRATEGY 3: DRAG & DROP (Last Resort)
console.log('Strategy 3: Drag & Drop Simulation...');
const dropEventInit = {
bubbles: true,
cancelable: true,
view: window,
dataTransfer: dataTransfer
};
// Try to find specific dropzone if inputElement is just a text area
const dropzone = document.querySelector('xapfileselectordropzone') ||
document.querySelector('.drop-zone') ||
document.body;
let dropTarget = (dropzone && dropzone !== document.body) ? dropzone : inputElement;
dropTarget.dispatchEvent(new DragEvent('dragenter', dropEventInit));
await sleep(50);
dropTarget.dispatchEvent(new DragEvent('dragover', dropEventInit));
await sleep(50);
dropTarget.dispatchEvent(new DragEvent('drop', dropEventInit));
console.log('Hybrid upload sequence completed.');
// We return true blindly because we tried everything.
return true;
}
// Auto-click generate/create button
async function autoClickGenerateButton() {
// Common selectors for Gemini's generate/create button
const generateButtonSelectors = [
'button[aria-label*="Generate"]',
'button[aria-label*="Create"]',
'button:has-text("Generate")',
'button:has-text("Create")',
'button[data-test-id*="generate"]',
'button[data-test-id*="create"]',
'.generate-button',
'.create-button'
];
// Try to find and click the generate button in a loop for a short period
const maxAttempts = 5;
for (let i = 0; i < maxAttempts; i++) {
for (const selector of generateButtonSelectors) {
try {
const button = document.querySelector(selector);
if (button && button.offsetParent !== null) { // Check if visible
console.log('Found generate button, clicking...', selector);
button.click();
await sleep(500);
return true;
}
} catch (e) {
// Continue to next selector
}
}
await sleep(500);
}
// Alternative: Look for buttons with specific text content
const allButtons = document.querySelectorAll('button');
for (const button of allButtons) {
const text = button.textContent.toLowerCase();
if ((text.includes('generate') || text.includes('create') || text.includes('สร้าง')) &&
button.offsetParent !== null) {
console.log('Found generate button by text, clicking...', button.textContent);
button.click();
await sleep(500);
return true;
}
}
console.log('Generate button not found, image may need manual generation');
return false;
}
// Wait for generated image to appear
async function waitForGeneratedImage(initialCount = 0) {
const maxWaitTime = 60000; // 60 seconds
const checkInterval = 1000; // Check every second
let elapsed = 0;
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
elapsed += checkInterval;
// Look for generated images
const images = document.querySelectorAll(SELECTORS.generatedImage);
if (images.length > initialCount) {
// Get the most recent image
const latestImage = images[images.length - 1];
const imageUrl = latestImage.src;
if (imageUrl && !imageUrl.includes('data:image')) {
clearInterval(interval);
// Send image URL back to side panel
chrome.runtime.sendMessage({
action: 'imageGenerated',
imageUrl: imageUrl
});
resolve(imageUrl);
}
}
if (elapsed >= maxWaitTime) {
clearInterval(interval);
reject(new Error('หมดเวลารอรูปภาพจาก Gemini'));
}
}, checkInterval);
});
}
// Utility: Wait for element to appear
function waitForElement(selector, timeout = 5000) {
return new Promise((resolve) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
// Utility: Sleep
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Monitor for manual image generation (if user generates without extension)
// -----------------------------------------------------------
// -----------------------------------------------------------
// Auto Download Logic (Persistent)
// -----------------------------------------------------------
let isAutoDownloadEnabled = false; // Default off, will sync with storage
const downloadedImages = new Set(); // Local cache of what we've seen/downloaded
// Initialize state from storage
chrome.storage.local.get(['autoDownload', 'downloadedHistory'], (result) => {
isAutoDownloadEnabled = result.autoDownload || false;
// 1. Load history into Set
if (result.downloadedHistory && Array.isArray(result.downloadedHistory)) {
result.downloadedHistory.forEach(url => downloadedImages.add(url));
}
// 2. Scan Existing DOM for images (The "Reload Extension" case)
// Any image currently visible is "old" and shouldn't be auto-downloaded again this session
// We add EVERYTHING currently on screen to the history to be safe.
const existingImages = document.querySelectorAll('img');
let addedCount = 0;
existingImages.forEach(img => {
if (img.src && img.src.startsWith('http')) {
// Just add it blindly to history so we don't process it again
if (!downloadedImages.has(img.src)) {
downloadedImages.add(img.src);
addedCount++;
}
}
});
if (addedCount > 0) {
console.log(`Marked ${addedCount} existing images as seen (skipped download).`);
saveDownloadHistory();
}
console.log('Auto Download status:', isAutoDownloadEnabled);
console.log(`Loaded ${downloadedImages.size} total images in history.`);
// Start observing ONLY if enabled
if (isAutoDownloadEnabled) {
startObserving();
}
});
// Listen for storage changes
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.autoDownload) {
isAutoDownloadEnabled = changes.autoDownload.newValue;
console.log('Auto Download status changed to:', isAutoDownloadEnabled);
if (isAutoDownloadEnabled) {
startObserving();
} else {
stopObserving();
}
}
});
// ... (previous code)
let observerActive = false;
let isInitialLoad = true;
// Set a grace period for the initial load
// This prevents the extension from downloading existing chat history as "new" images
// when the user reloads the page.
setTimeout(() => {
isInitialLoad = false;
console.log('Initial load grace period ended. Ready to capture NEW images.');
}, 5000);
const autoDownloadObserver = new MutationObserver((mutations) => {
// Only run if we are generally interested, though checking inside loop is safer for dynamic toggling
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
// Specific validaiton for IMG tag
if (node.tagName === 'IMG') {
checkAndDownloadImage(node);
}
// Sometimes images are nested in added containers
if (node.querySelectorAll) {
const images = node.querySelectorAll('img');
images.forEach(checkAndDownloadImage);
}
}
}
});
let saveTimeout;
function debouncedSaveHistory() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveDownloadHistory, 1000);
}
function checkAndDownloadImage(img) {
// 1. Check if it's already processed (in local Set)
// Safety: Duplicate Check using src as key
if (downloadedImages.has(img.src)) return;
// 2. Wait for image to load to check dimensions
if (!img.complete) {
img.onload = () => checkAndDownloadImage(img);
return;
}
if (!img.src) return;
// --- STRICT FILTERING LOGIC ---
// Condition A (Size): Width OR Height > 300px
// Prevents icons, avatars, stickers
const isBigEnough = img.naturalWidth > 300 || img.naturalHeight > 300;
if (!isBigEnough) {
return;
}
// Condition B (Source):
// - URL must not be small Data URI (handled partly by size check, but user wants strict source check)
// - Must not be Blob from user upload
// - If possible, check if in model-response container
// Check ancestors for container type
const messageContainer = img.closest('[data-message-author-role], .model-response, .user-message, .query-container');
let isModelResponse = false;
let isUserUpload = false;
if (messageContainer) {
const role = messageContainer.getAttribute('data-message-author-role');
if (role === 'user') {
isUserUpload = true;
} else if (role === 'model') {
isModelResponse = true; // High confidence
} else if (messageContainer.classList.contains('user-message') || messageContainer.classList.contains('query-container')) {
isUserUpload = true;
} else if (messageContainer.classList.contains('model-response')) {
isModelResponse = true;
}
}
// B.1: Reject Explicit User Uploads
if (isUserUpload) {
console.log('Ignored: Image is in User message', img.src);
downloadedImages.add(img.src);
return;
}
// B.2: Source URL Check
const src = img.src;
const isBlob = src.startsWith('blob:');
const isData = src.startsWith('data:');
if ((isBlob || isData) && !isModelResponse) {
console.log('Ignored: Blob/Data URI without Model context (Likely user upload)', src);
return;
}
// B.3: Input Area Check (Composing phrase)
if (img.closest('div[contenteditable="true"], .input-area, textarea, .text-input-field')) {
return;
}
// B.4: Avatar Check (googleusercontent specific)
if (src.includes('googleusercontent.com/a/')) {
return;
}
// If we passed all filters:
// console.log('Pass: Valid Generated Image detected:', src);
// Add to handled set immediately
downloadedImages.add(src);
debouncedSaveHistory(); // Use debounced save
// CRITICAL: Check if we are in the initial load phase (Reload protection)
if (isInitialLoad) {
console.log('Skipped Download (Initial Load):', src);
return;
}
if (isAutoDownloadEnabled) {
console.log('Auto-downloading image...');
chrome.runtime.sendMessage({
action: 'downloadImage',
url: src
});
}
}
function saveDownloadHistory() {
// Convert Set to Array
const historyArray = Array.from(downloadedImages);
// Limit history size to prevent storage bloat (e.g., last 500 images)
if (historyArray.length > 500) {
historyArray.splice(0, historyArray.length - 500);
}
chrome.storage.local.set({ downloadedHistory: historyArray });
}
function startObserving() {
if (observerActive) return; // Prevent double attach
// Start observing specifically for images appearing in the chat
autoDownloadObserver.observe(document.body, {
childList: true,
subtree: true
});
observerActive = true;
console.log('Auto Download Observer STARTED');
}
function stopObserving() {
if (!observerActive) return;
autoDownloadObserver.disconnect();
observerActive = false;
console.log('Auto Download Observer STOPPED (Resource Saved)');
}

112
scripts/grid_generator.js Normal file
View File

@@ -0,0 +1,112 @@
// Grid Generator logic
// Ported from python engine/grid_generator.py
class GridGenerator {
static BORDER_WIDTH = 5;
static BORDER_COLOR = '#FFFFFF';
/**
* Generates a grid image
* @param {Array<HTMLImageElement>} images - Array of loaded image elements
* @param {GridTemplate} template - The template to use
* @param {Object} offsets - Optional offsets map { slotIndex: {x, y} }
* @param {string} fakeNumber - Optional text to display on the last slot (e.g. "+5")
* @returns {HTMLCanvasElement} The canvas with the generated grid
*/
static generate(images, template, offsets = {}, fakeNumber = null) {
const canvas = document.createElement('canvas');
canvas.width = template.canvasSize[0];
canvas.height = template.canvasSize[1];
const ctx = canvas.getContext('2d');
// Fill background with border color
ctx.fillStyle = this.BORDER_COLOR;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const count = Math.min(images.length, template.slots.length);
for (let i = 0; i < count; i++) {
const slot = template.slots[i];
const img = images[i];
// Calculate the actual image size (subtract borders)
const border = this.BORDER_WIDTH;
const actualW = slot.w - (border * 2);
const actualH = slot.h - (border * 2);
const actualX = slot.x + border;
const actualY = slot.y + border;
// Get offset for this slot (default to center 0,0)
// Range [-1, 1]
const offset = offsets[i] || { x: 0, y: 0 };
// Calculate crop
const imgAspect = img.naturalWidth / img.naturalHeight;
const slotAspect = actualW / actualH;
let sourceX, sourceY, sourceW, sourceH;
if (imgAspect > slotAspect) {
// Image is wider - fit to height
sourceH = img.naturalHeight;
sourceW = sourceH * slotAspect;
const maxOffset = (img.naturalWidth - sourceW) / 2;
const panX = maxOffset * offset.x;
sourceX = (img.naturalWidth - sourceW) / 2 + panX;
sourceY = 0;
} else {
// Image is taller - fit to width
sourceW = img.naturalWidth;
sourceH = sourceW / slotAspect;
const maxOffset = (img.naturalHeight - sourceH) / 2;
const panY = maxOffset * offset.y;
sourceX = 0;
sourceY = (img.naturalHeight - sourceH) / 2 + panY;
}
// Draw image to canvas
ctx.drawImage(
img,
sourceX, sourceY, sourceW, sourceH, // Source
actualX, actualY, actualW, actualH // Destination
);
}
// Draw fake number in last slot if provided
if (template.slots.length > 0 && typeof fakeNumber === 'string' && fakeNumber.length > 0) {
const lastSlotIndex = template.slots.length - 1;
const lastSlot = template.slots[lastSlotIndex];
// Calculate slot dimensions (including border logic)
const border = this.BORDER_WIDTH;
const actualW = lastSlot.w - (border * 2);
const actualH = lastSlot.h - (border * 2);
const actualX = lastSlot.x + border;
const actualY = lastSlot.y + border;
// 1. Draw Semi-Transparent Overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; // 50% opacity black
ctx.fillRect(actualX, actualY, actualW, actualH);
// 2. Draw Text centered
// Responsive font size: 20% of slot height
const fontSize = Math.floor(actualH * 0.2);
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Calculate center of slot
const centerX = actualX + (actualW / 2);
const centerY = actualY + (actualH / 2);
ctx.fillText(fakeNumber, centerX, centerY);
}
return canvas;
}
}

144
scripts/grid_templates.js Normal file
View File

@@ -0,0 +1,144 @@
// Grid Templates definition
// Ported from python engine/templates.py
class Slot {
constructor(x, y, w, h, description = "") {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.description = description;
}
}
class GridTemplate {
constructor(id, name, canvasSize, slots, expectedCount) {
this.id = id;
this.name = name;
this.canvasSize = canvasSize; // [width, height]
this.slots = slots;
this.expectedCount = expectedCount;
}
}
class TemplateManager {
static getTemplates() {
const templates = [];
// Template 1: 3 images - Left portrait + 2 stacked squares
// Canvas: 1920 x 1920 (1:1)
templates.push(new GridTemplate(
1,
"Portrait + 2 Squares",
[1920, 1920],
[
new Slot(0, 0, 960, 1920, "Left Portrait"),
new Slot(960, 0, 960, 960, "Right Top"),
new Slot(960, 960, 960, 960, "Right Bottom")
],
3
));
// Template 2: 5 images - 2 squares left + 3 stacked right
// Canvas: 1920 x 1920 (1:1)
templates.push(new GridTemplate(
2,
"2 Squares + 3 Stacked",
[1920, 1920],
[
// Left Column
new Slot(0, 0, 960, 960, "Left Top"),
new Slot(0, 960, 960, 960, "Left Bottom"),
// Right Column
new Slot(960, 0, 960, 640, "Right Top"),
new Slot(960, 640, 960, 640, "Right Mid"),
new Slot(960, 1280, 960, 640, "Right Bot"),
],
5
));
// Template 3: 4 images - Main left + 3 stacked squares right
// Canvas: 1920 x 1920 (1:1)
templates.push(new GridTemplate(
3,
"Main + 3 Squares",
[1920, 1920],
[
new Slot(0, 0, 1280, 1920, "Left Main"),
new Slot(1280, 0, 640, 640, "Right Top"),
new Slot(1280, 640, 640, 640, "Right Mid"),
new Slot(1280, 1280, 640, 640, "Right Bot"),
],
4
));
// Template 4: 3 images - Top wide + 2 squares bottom
// Canvas: 1920 x 1920 (1:1)
templates.push(new GridTemplate(
4,
"Wide + 2 Squares",
[1920, 1920],
[
new Slot(0, 0, 1920, 960, "Top Wide"),
new Slot(0, 960, 960, 960, "Bottom Left"),
new Slot(960, 960, 960, 960, "Bottom Right"),
],
3
));
// Template 5: 4 images - Top main + 3 squares bottom
// Canvas: 1920 x 1920 (1:1)
templates.push(new GridTemplate(
5,
"Main + 3 Bottom Squares",
[1920, 1920],
[
new Slot(0, 0, 1920, 1280, "Top Main"),
new Slot(0, 1280, 640, 640, "Bot Left"),
new Slot(640, 1280, 640, 640, "Bot Mid"),
new Slot(1280, 1280, 640, 640, "Bot Right"),
],
4
));
// Template 6: 4 images - Perfect 2x2 grid
// Canvas: 1920 x 1920 (1:1)
templates.push(new GridTemplate(
6,
"2x2 Grid",
[1920, 1920],
[
new Slot(0, 0, 960, 960, "Top Left"),
new Slot(960, 0, 960, 960, "Top Right"),
new Slot(0, 960, 960, 960, "Bottom Left"),
new Slot(960, 960, 960, 960, "Bottom Right"),
],
4
));
// Template 7: 5 images - 2 large top + 3 small bottom
// Canvas: 1920 x 1920 (1:1)
templates.push(new GridTemplate(
7,
"2 Large + 3 Small",
[1920, 1920],
[
// Top Row - 2 large squares
new Slot(0, 0, 960, 960, "Top Left"),
new Slot(960, 0, 960, 960, "Top Right"),
// Bottom Row - 3 smaller rectangles
new Slot(0, 960, 640, 960, "Bottom Left"),
new Slot(640, 960, 640, 960, "Bottom Center"),
new Slot(1280, 960, 640, 960, "Bottom Right"),
],
5
));
return templates;
}
static getTemplateById(id) {
const templates = this.getTemplates();
return templates.find(t => t.id === id) || templates[0];
}
}