Compare commits

..

2 Commits

Author SHA1 Message Date
39ea45fade Merge branch 'master' of https://git.yasue.org/ken/RandomVideoMaker 2025-09-25 07:48:11 +02:00
eff12f546e face generator 2025-09-25 07:47:58 +02:00
7 changed files with 755 additions and 1 deletions

9
.clinerules/pinterest.md Normal file
View File

@ -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

View File

@ -9,7 +9,8 @@
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"db:schema": "ts-node src/schema.ts", "db:schema": "ts-node src/schema.ts",
"db:test": "ts-node src/testmysql.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": [], "keywords": [],
"author": "", "author": "",

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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<string[]> {
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);
});

View File

@ -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<string> {
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 };

116
src/lib/pinterest.ts Normal file
View File

@ -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<string | null> {
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<string[]> {
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 <img> 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();
}
}