From eff12f546eba55e064a7713247fba637b33f2742 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Sep 2025 07:47:58 +0200 Subject: [PATCH] face generator --- .clinerules/pinterest.md | 9 + package.json | 3 +- src/comfyworkflows/edit_image_2_qwen.json | 194 ++++++++++++++++++ src/comfyworkflows/edit_image_qwen.json | 193 +++++++++++++++++ src/imageconverter/pinterest_face_portrait.ts | 172 ++++++++++++++++ src/lib/image-converter.ts | 69 +++++++ src/lib/pinterest.ts | 116 +++++++++++ 7 files changed, 755 insertions(+), 1 deletion(-) create mode 100644 .clinerules/pinterest.md create mode 100644 src/comfyworkflows/edit_image_2_qwen.json create mode 100644 src/comfyworkflows/edit_image_qwen.json create mode 100644 src/imageconverter/pinterest_face_portrait.ts create mode 100644 src/lib/image-converter.ts create mode 100644 src/lib/pinterest.ts diff --git a/.clinerules/pinterest.md b/.clinerules/pinterest.md new file mode 100644 index 0000000..48fa98b --- /dev/null +++ b/.clinerules/pinterest.md @@ -0,0 +1,9 @@ +# Pinterest operations +Use this file src\lib\pinterest.ts for pinterest operation. +You can modify if needed. + +# Get PinId from Keyword +Use this method getPinUrlFromPinterest + +# Get image from pinId +Use this method downloadImageFromPin \ No newline at end of file diff --git a/package.json b/package.json index 6854228..b08cde2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "test": "echo \"Error: no test specified\" && exit 1", "db:schema": "ts-node src/schema.ts", "db:test": "ts-node src/testmysql.ts", - "infinity:start": "ts-node src/infinityvideo_generator/start.ts" + "infinity:start": "ts-node src/infinityvideo_generator/start.ts", + "convert:pinterest-face": "ts-node src/imageconverter/pinterest_face_portrait.ts" }, "keywords": [], "author": "", diff --git a/src/comfyworkflows/edit_image_2_qwen.json b/src/comfyworkflows/edit_image_2_qwen.json new file mode 100644 index 0000000..ca6740d --- /dev/null +++ b/src/comfyworkflows/edit_image_2_qwen.json @@ -0,0 +1,194 @@ +{ + "1": { + "inputs": { + "unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors", + "weight_dtype": "default" + }, + "class_type": "UNETLoader", + "_meta": { + "title": "Load Diffusion Model" + } + }, + "2": { + "inputs": { + "clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors", + "type": "qwen_image", + "device": "default" + }, + "class_type": "CLIPLoader", + "_meta": { + "title": "Load CLIP" + } + }, + "3": { + "inputs": { + "vae_name": "qwen_image_vae.safetensors" + }, + "class_type": "VAELoader", + "_meta": { + "title": "Load VAE" + } + }, + "4": { + "inputs": { + "lora_name": "Qwen-Image-Lightning-8steps-V2.0.safetensors", + "strength_model": 1, + "model": [ + "1", + 0 + ] + }, + "class_type": "LoraLoaderModelOnly", + "_meta": { + "title": "LoraLoaderModelOnly" + } + }, + "5": { + "inputs": { + "conditioning": [ + "11", + 0 + ] + }, + "class_type": "ConditioningZeroOut", + "_meta": { + "title": "ConditioningZeroOut" + } + }, + "7": { + "inputs": { + "seed": 135126386717887, + "steps": 8, + "cfg": 1, + "sampler_name": "euler", + "scheduler": "beta", + "denoise": 1, + "model": [ + "4", + 0 + ], + "positive": [ + "11", + 0 + ], + "negative": [ + "5", + 0 + ], + "latent_image": [ + "11", + 6 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "KSampler" + } + }, + "8": { + "inputs": { + "samples": [ + "7", + 0 + ], + "vae": [ + "3", + 0 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "11": { + "inputs": { + "prompt": [ + "21", + 0 + ], + "enable_resize": true, + "enable_vl_resize": true, + "upscale_method": "lanczos", + "crop": "disabled", + "instruction": "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", + "clip": [ + "2", + 0 + ], + "vae": [ + "3", + 0 + ], + "image1": [ + "24", + 0 + ], + "image2": [ + "15", + 0 + ] + }, + "class_type": "TextEncodeQwenImageEditPlus_lrzjason", + "_meta": { + "title": "TextEncodeQwenImageEditPlus 小志Jason(xiaozhijason)" + } + }, + "15": { + "inputs": { + "image": "ComfyUI_00067_.png" + }, + "class_type": "LoadImage", + "_meta": { + "title": "Load Image" + } + }, + "20": { + "inputs": { + "filename_prefix": "qwenedit", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "Save Image" + } + }, + "21": { + "inputs": { + "value": "change the face and hairstyle of image1 to image2" + }, + "class_type": "PrimitiveStringMultiline", + "_meta": { + "title": "String (Multiline)" + } + }, + "24": { + "inputs": { + "measurement": "pixels", + "width": 720, + "height": 1280, + "fit": "pad", + "method": "nearest-exact", + "image": [ + "63", + 0 + ] + }, + "class_type": "Image Resize (rgthree)", + "_meta": { + "title": "Image Resize (rgthree)" + } + }, + "63": { + "inputs": { + "image": "00d3c030856044bb99b804b4ef88f4be.jpg" + }, + "class_type": "LoadImage", + "_meta": { + "title": "Load Image" + } + } +} \ No newline at end of file diff --git a/src/comfyworkflows/edit_image_qwen.json b/src/comfyworkflows/edit_image_qwen.json new file mode 100644 index 0000000..60d60e7 --- /dev/null +++ b/src/comfyworkflows/edit_image_qwen.json @@ -0,0 +1,193 @@ +{ + "1": { + "inputs": { + "unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors", + "weight_dtype": "default" + }, + "class_type": "UNETLoader", + "_meta": { + "title": "Load Diffusion Model" + } + }, + "2": { + "inputs": { + "clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors", + "type": "qwen_image", + "device": "default" + }, + "class_type": "CLIPLoader", + "_meta": { + "title": "Load CLIP" + } + }, + "3": { + "inputs": { + "vae_name": "qwen_image_vae.safetensors" + }, + "class_type": "VAELoader", + "_meta": { + "title": "Load VAE" + } + }, + "4": { + "inputs": { + "lora_name": "Qwen-Image-Lightning-8steps-V2.0.safetensors", + "strength_model": 1, + "model": [ + "1", + 0 + ] + }, + "class_type": "LoraLoaderModelOnly", + "_meta": { + "title": "LoraLoaderModelOnly" + } + }, + "5": { + "inputs": { + "conditioning": [ + "11", + 0 + ] + }, + "class_type": "ConditioningZeroOut", + "_meta": { + "title": "ConditioningZeroOut" + } + }, + "7": { + "inputs": { + "seed": 838097333311955, + "steps": 8, + "cfg": 1, + "sampler_name": "euler", + "scheduler": "beta", + "denoise": 1, + "model": [ + "4", + 0 + ], + "positive": [ + "11", + 0 + ], + "negative": [ + "5", + 0 + ], + "latent_image": [ + "11", + 6 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "KSampler" + } + }, + "8": { + "inputs": { + "samples": [ + "7", + 0 + ], + "vae": [ + "3", + 0 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "11": { + "inputs": { + "prompt": [ + "21", + 0 + ], + "enable_resize": true, + "enable_vl_resize": true, + "upscale_method": "lanczos", + "crop": "disabled", + "instruction": "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", + "clip": [ + "2", + 0 + ], + "vae": [ + "3", + 0 + ], + "image1": [ + "24", + 0 + ] + }, + "class_type": "TextEncodeQwenImageEditPlus_lrzjason", + "_meta": { + "title": "TextEncodeQwenImageEditPlus 小志Jason(xiaozhijason)" + } + }, + "20": { + "inputs": { + "filename_prefix": "qwenedit", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "Save Image" + } + }, + "21": { + "inputs": { + "value": "change camera angle to closeup face from image1, change background to light gray with faing gradient, change face angle to look at directry look at camera" + }, + "class_type": "PrimitiveStringMultiline", + "_meta": { + "title": "String (Multiline)" + } + }, + "24": { + "inputs": { + "measurement": "pixels", + "width": 720, + "height": 1280, + "fit": "contain", + "method": "nearest-exact", + "image": [ + "64", + 0 + ] + }, + "class_type": "Image Resize (rgthree)", + "_meta": { + "title": "Image Resize (rgthree)" + } + }, + "64": { + "inputs": { + "image": "1337074888177434_1758776251440_2.png" + }, + "class_type": "LoadImage", + "_meta": { + "title": "Load Image" + } + }, + "65": { + "inputs": { + "images": [ + "24", + 0 + ] + }, + "class_type": "PreviewImage", + "_meta": { + "title": "Preview Image" + } + } +} \ No newline at end of file diff --git a/src/imageconverter/pinterest_face_portrait.ts b/src/imageconverter/pinterest_face_portrait.ts new file mode 100644 index 0000000..0600e73 --- /dev/null +++ b/src/imageconverter/pinterest_face_portrait.ts @@ -0,0 +1,172 @@ +import dotenv from 'dotenv'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { logger } from '../lib/logger'; +import { getPinUrlFromPinterest, downloadImageFromPin } from '../lib/pinterest'; +import { convertImage } from '../lib/image-converter'; + +dotenv.config(); + +const KEYWORDS = [ + 'teen girl portrait', + 'woman portrait', + 'woman face close', + 'teen face close', + 'beautiful woman face closeup', + 'beautiful teen', + 'russian teen', + 'skandinavian teen', + 'uk teen', + 'asian teen', + 'east european teen', + 'cute teen', + 'beautiful woman', + 'russian woman', + 'skandinavian woman', + 'uk woman', + 'asian woman', + 'east european woman', + 'cute woman',]; +const TARGET_COUNT = Number(process.env.IMAGE_COUNT || 20); +const PROMPT = + `change camera angle to closeup face from image1, + change background to light gray with faing gradient, + change face angle to look at directry look at camera˛ + change lighting to soft light, + change face expression to neautral expression, + change age to 20 years old, + `; + +type ServerCfg = { baseUrl: string; outputDir: string; inputDir: string }; + +function getServerConfig(): ServerCfg { + const candidates = [ + { baseUrl: process.env.SERVER1_COMFY_BASE_URL, outputDir: process.env.SERVER1_COMFY_OUTPUT_DIR }, + //{ baseUrl: process.env.SERVER2_COMFY_BASE_URL, outputDir: process.env.SERVER2_COMFY_OUTPUT_DIR }, + ].filter((s): s is { baseUrl: string; outputDir: string } => !!s.baseUrl && !!s.outputDir); + + if (candidates.length === 0) { + throw new Error( + 'No ComfyUI server configured. Please set SERVER1_COMFY_BASE_URL/OUTPUT_DIR or SERVER2_COMFY_BASE_URL/OUTPUT_DIR in .env' + ); + } + const chosen = candidates[0]; + const inputDir = chosen.outputDir.replace('output', 'input'); + return { ...chosen, inputDir }; +} + +function sleep(ms: number) { + return new Promise((res) => setTimeout(res, ms)); +} + +async function ensureDir(p: string) { + try { + await fs.mkdir(p, { recursive: true }); + } catch { + // ignore + } +} + +async function collectImages(keyword: string, total: number): Promise { + const results: string[] = []; + // ensure local download dir exists (pinterest.ts also ensures it, but harmless here) + await ensureDir(path.join(process.cwd(), 'download')); + + while (results.length < total) { + try { + const pinUrl = await getPinUrlFromPinterest(keyword); + if (!pinUrl) { + logger.warn('No pin URL found, retrying...'); + await sleep(1500); + continue; + } + const remaining = total - results.length; + // attempt to grab up to 5 per pin to reduce churn + const batchTarget = Math.min(5, remaining); + const imgs = await downloadImageFromPin(pinUrl, batchTarget); + if (imgs && imgs.length > 0) { + results.push(...imgs); + logger.info(`Downloaded ${imgs.length} image(s) from ${pinUrl}.Progress: ${results.length}/${total}`); + } else { + logger.warn(`Pin yielded no downloadable images: ${pinUrl}`); + } + await sleep(1000 + Math.random() * 1000); + } catch (err) { + logger.error('Error while collecting images:', err); + await sleep(2000); + } + } + return results.slice(0, total); +} + +async function processImages(imagePaths: string[], server: ServerCfg) { + await ensureDir(server.inputDir); + + for (const localImagePath of imagePaths) { + const baseName = path.basename(localImagePath); + const serverInputPath = path.join(server.inputDir, baseName); + + try { + // copy source image into ComfyUI input dir so workflow node can find it by filename + await fs.copyFile(localImagePath, serverInputPath); + logger.info(`Copied ${localImagePath} -> ${serverInputPath}`); + + // Run conversion (sequential to avoid output race conditions) + const generatedPath = await convertImage( + PROMPT, + baseName, + server.baseUrl, + server.outputDir, + { width: 720, height: 1280 } // portrait + ); + logger.info(`Generated image: ${generatedPath}`); + } catch (err) { + logger.error(`Failed to convert ${localImagePath}:`, err); + } finally { + // cleanup both server input copy and local downloaded file + try { + await fs.unlink(serverInputPath); + } catch { } + try { + await fs.unlink(localImagePath); + } catch { } + await sleep(500); + } + } +} + +async function main() { + const server = getServerConfig(); + + // Infinite loop as requested + while (true) { + + for (const KEYWORD of KEYWORDS) { + logger.info(`Starting Pinterest image conversion loop for keyword: "${KEYWORD}" (target ${TARGET_COUNT})`); + try { + const images = await collectImages(KEYWORD, TARGET_COUNT); + logger.info(`Collected ${images.length} image(s). Starting conversion...`); + await processImages(images, server); + logger.info('Iteration completed. Sleeping before next cycle...'); + } catch (err) { + logger.error('Iteration failed:', err); + } + + // brief pause between iterations + await sleep(10000); + } + + } +} + +process.on('unhandledRejection', (reason) => { + logger.error('UnhandledRejection:', reason); +}); + +process.on('uncaughtException', (err) => { + logger.error('UncaughtException:', err); +}); + +main().catch((e) => { + logger.error('Fatal error:', e); +}); diff --git a/src/lib/image-converter.ts b/src/lib/image-converter.ts new file mode 100644 index 0000000..0dc4bd8 --- /dev/null +++ b/src/lib/image-converter.ts @@ -0,0 +1,69 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import axios from 'axios'; +import dotenv from 'dotenv'; + +dotenv.config(); + +interface ImageSize { + width: number; + height: number; +} + +async function convertImage( + prompt: string, + newFileName: string, + comfyBaseUrl: string, + comfyOutputDir: string, + size: ImageSize = { width: 720, height: 1280 } +): Promise { + const COMFY_BASE_URL = comfyBaseUrl.replace(/\/$/, ''); + const COMFY_OUTPUT_DIR = comfyOutputDir; + let workflow; + + workflow = JSON.parse(await fs.readFile('src/comfyworkflows/edit_image_qwen.json', 'utf-8')); + workflow['21']['inputs']['value'] = prompt; + workflow['24']['inputs']['width'] = size.width; + workflow['24']['inputs']['height'] = size.height; + workflow['64']['inputs']['image'] = newFileName; + + const response = await axios.post(`${COMFY_BASE_URL}/prompt`, { prompt: workflow }); + const promptId = response.data.prompt_id; + + let history; + do { + await new Promise(resolve => setTimeout(resolve, 1000)); + const historyResponse = await axios.get(`${COMFY_BASE_URL}/history/${promptId}`); + history = historyResponse.data[promptId]; + } while (!history || Object.keys(history.outputs).length === 0); + + const files = await fs.readdir(COMFY_OUTPUT_DIR!); + const generatedFiles = files.filter(file => file.startsWith('qwenedit')); + + const fileStats = await Promise.all( + generatedFiles.map(async (file) => { + const stat = await fs.stat(path.join(COMFY_OUTPUT_DIR!, file)); + return { file, mtime: stat.mtime }; + }) + ); + + fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + const latestFile = fileStats[0].file; + const newFilePath = path.resolve('./generated', newFileName); + + await fs.mkdir('./generated', { recursive: true }); + + const sourcePath = path.join(COMFY_OUTPUT_DIR!, latestFile); + try { + await fs.unlink(newFilePath); + } catch (err) { + // ignore if not exists + } + + await fs.copyFile(sourcePath, newFilePath); + + return newFilePath; +} + +export { convertImage }; diff --git a/src/lib/pinterest.ts b/src/lib/pinterest.ts new file mode 100644 index 0000000..e921439 --- /dev/null +++ b/src/lib/pinterest.ts @@ -0,0 +1,116 @@ +import { logger } from './logger'; +import * as fs from 'fs/promises'; +import dotenv from 'dotenv'; +import path from 'path'; +import puppeteer from 'puppeteer'; + +export async function getPinUrlFromPinterest(keyword: string): Promise { + const browser = await puppeteer.launch({ headless: true }); + const page = await browser.newPage(); + await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'); + await page.setViewport({ width: 1920, height: 1080 }); + try { + const searchUrl = `https://www.pinterest.com/search/pins/?q=${encodeURIComponent(keyword)}`; + await page.goto(searchUrl, { waitUntil: 'networkidle2' }); + + const scrollCount = Math.floor(Math.random() * 5) + 1; + logger.info(`Scrolling ${scrollCount} times...`); + for (let i = 0; i < scrollCount; i++) { + await page.evaluate('window.scrollTo(0, document.body.scrollHeight)'); + await new Promise(resolve => setTimeout(resolve, Math.random() * 1000 + 1000)); + } + + const pinLinks = await page.$$eval('a', (anchors) => + anchors.map((a) => a.href).filter((href) => href.includes('/pin/')) + ); + + if (pinLinks.length > 0) { + return pinLinks[Math.floor(Math.random() * pinLinks.length)]; + } + return null; + } catch (error) { + logger.error('Error while getting pin URL from Pinterest:', error); + return null; + } finally { + await browser.close(); + } +} + + +// Download up to `count` images from a pin URL by opening the pin page and scro lling up to 5 times to trigger lazy loading +// Returns an array of saved image paths (may be empty) +export async function downloadImageFromPin(pinUrl: string, count: number = 1): Promise { + const browser = await puppeteer.launch({ headless: false }); + const page = await browser.newPage(); + await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'); + await page.setViewport({ width: 1920, height: 1080 }); + try { + await page.goto(pinUrl, { waitUntil: 'networkidle2', timeout: 30000 }); + for (let i = 0; i < 3; i++) { + await page.evaluate('window.scrollTo(0, document.body.scrollHeight)'); + await new Promise((r) => setTimeout(r, 700 + Math.random() * 800)); + } + + const imgs: string[] = await page.$$eval('img', imgs => { + // For each try to extract the 4x (original) URL from srcset. + // srcset example: + // "https://i.pinimg.com/236x/...jpg 1x, https://i.pinimg.com/474x/...jpg 2x, https://i.pinimg.com/736x/...jpg 3x, https://i.pinimg.com/originals/...jpg 4x" + const urls: string[] = imgs.map(img => { + const srcset = (img as HTMLImageElement).getAttribute('srcset') || ''; + if (!srcset) return ''; + const parts = srcset.split(',').map(p => p.trim()); + for (const part of parts) { + const m = part.match(/^(\S+)\s+4x$/); + if (m && m[1]) return m[1]; + } + // fallback: if src contains "originals" return src + const src = (img as HTMLImageElement).src || ''; + if (src.includes('/originals/')) return src; + return ''; + }).filter(s => !!s && s.includes('pinimg')); + return urls; + }); + + if (!imgs || imgs.length === 0) { + logger.warn(`No image src (4x) found on pin page ${pinUrl}`); + return []; + } + + // shuffle and pick up to `count` unique images + const shuffled = imgs.slice().sort(() => 0.5 - Math.random()); + const chosen = shuffled.slice(0, Math.min(count, shuffled.length)); + + const outDir = path.join(process.cwd(), 'download'); + await fs.mkdir(outDir, { recursive: true }); + + const results: string[] = []; + for (let i = 0; i < chosen.length; i++) { + const src = chosen[i]; + try { + const imgPage = await browser.newPage(); + const resp = await imgPage.goto(src, { timeout: 30000, waitUntil: 'networkidle2' }); + if (!resp) { + logger.warn(`Failed to fetch image ${src} from ${pinUrl}`); + await imgPage.close(); + continue; + } + const buffer = await resp.buffer(); + const pinId = pinUrl.split('/').filter(Boolean).pop() || `pin_${Date.now()}`; + const timestamp = Date.now(); + const outPath = path.join(outDir, `${pinId}_${timestamp}_${i}.png`); + await fs.writeFile(outPath, buffer); + results.push(outPath); + await imgPage.close(); + } catch (err) { + logger.error(`Failed to download image ${src} from ${pinUrl}:`, err); + } + } + + return results; + } catch (err) { + logger.error(`Failed to download images from ${pinUrl}:`, err); + return []; + } finally { + await browser.close(); + } +}