face generator
This commit is contained in:
9
.clinerules/pinterest.md
Normal file
9
.clinerules/pinterest.md
Normal 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
|
||||||
@ -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": "",
|
||||||
|
|||||||
194
src/comfyworkflows/edit_image_2_qwen.json
Normal file
194
src/comfyworkflows/edit_image_2_qwen.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/comfyworkflows/edit_image_qwen.json
Normal file
193
src/comfyworkflows/edit_image_qwen.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/imageconverter/pinterest_face_portrait.ts
Normal file
172
src/imageconverter/pinterest_face_portrait.ts
Normal 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);
|
||||||
|
});
|
||||||
69
src/lib/image-converter.ts
Normal file
69
src/lib/image-converter.ts
Normal 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
116
src/lib/pinterest.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user