studio-lumiere-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/annotate-image/SKILL.md +99 -0
- package/.agents/skills/config-troubleshooting/SKILL.md +97 -0
- package/.agents/skills/generate-images/SKILL.md +667 -0
- package/.agents/skills/generate-video/SKILL.md +328 -0
- package/.agents/skills/image-grid/SKILL.md +96 -0
- package/.agents/skills/image-overlay/SKILL.md +66 -0
- package/.agents/skills/image-overlay/agents/openai.yaml +4 -0
- package/.agents/skills/image-overlay/scripts/overlay-image.js +218 -0
- package/.agents/skills/muse-management/SKILL.md +232 -0
- package/.agents/skills/refine-images/SKILL.md +192 -0
- package/.agents/skills/tired-girl/SKILL.md +131 -0
- package/.env.example +2 -0
- package/AGENTS.md +66 -0
- package/README.md +96 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +214 -0
- package/dist/cli.js.map +1 -0
- package/dist/clients/geminiClient.d.ts +37 -0
- package/dist/clients/geminiClient.js +129 -0
- package/dist/clients/geminiClient.js.map +1 -0
- package/dist/config/constants.d.ts +63 -0
- package/dist/config/constants.js +1005 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/options.d.ts +1 -0
- package/dist/config/options.js +2 -0
- package/dist/config/options.js.map +1 -0
- package/dist/config/templates.d.ts +3 -0
- package/dist/config/templates.js +4 -0
- package/dist/config/templates.js.map +1 -0
- package/dist/config/tiredGirl.d.ts +3 -0
- package/dist/config/tiredGirl.js +9 -0
- package/dist/config/tiredGirl.js.map +1 -0
- package/dist/image/annotate.d.ts +2 -0
- package/dist/image/annotate.js +119 -0
- package/dist/image/annotate.js.map +1 -0
- package/dist/image/grid.d.ts +2 -0
- package/dist/image/grid.js +44 -0
- package/dist/image/grid.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/pipelines/createMuse.d.ts +3 -0
- package/dist/pipelines/createMuse.js +49 -0
- package/dist/pipelines/createMuse.js.map +1 -0
- package/dist/pipelines/generateImages.d.ts +2 -0
- package/dist/pipelines/generateImages.js +140 -0
- package/dist/pipelines/generateImages.js.map +1 -0
- package/dist/pipelines/generateTiredGirl.d.ts +2 -0
- package/dist/pipelines/generateTiredGirl.js +73 -0
- package/dist/pipelines/generateTiredGirl.js.map +1 -0
- package/dist/pipelines/generateVideo.d.ts +2 -0
- package/dist/pipelines/generateVideo.js +27 -0
- package/dist/pipelines/generateVideo.js.map +1 -0
- package/dist/pipelines/refineImage.d.ts +2 -0
- package/dist/pipelines/refineImage.js +28 -0
- package/dist/pipelines/refineImage.js.map +1 -0
- package/dist/pipelines/resolve.d.ts +11 -0
- package/dist/pipelines/resolve.js +74 -0
- package/dist/pipelines/resolve.js.map +1 -0
- package/dist/pipelines/upscaleImage.d.ts +2 -0
- package/dist/pipelines/upscaleImage.js +23 -0
- package/dist/pipelines/upscaleImage.js.map +1 -0
- package/dist/prompt/buildPrompt.d.ts +4 -0
- package/dist/prompt/buildPrompt.js +322 -0
- package/dist/prompt/buildPrompt.js.map +1 -0
- package/dist/prompt/tiredGirlPrompt.d.ts +3 -0
- package/dist/prompt/tiredGirlPrompt.js +33 -0
- package/dist/prompt/tiredGirlPrompt.js.map +1 -0
- package/dist/storage/files.d.ts +15 -0
- package/dist/storage/files.js +34 -0
- package/dist/storage/files.js.map +1 -0
- package/dist/storage/museStore.d.ts +5 -0
- package/dist/storage/museStore.js +26 -0
- package/dist/storage/museStore.js.map +1 -0
- package/dist/types.d.ts +169 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/examples/generate.d.ts +1 -0
- package/examples/generate.js +28 -0
- package/examples/generate.js.map +1 -0
- package/examples/generate.ts +30 -0
- package/examples/muse.d.ts +1 -0
- package/examples/muse.js +18 -0
- package/examples/muse.js.map +1 -0
- package/examples/muse.ts +20 -0
- package/examples/video.d.ts +1 -0
- package/examples/video.js +18 -0
- package/examples/video.js.map +1 -0
- package/examples/video.ts +20 -0
- package/logo-round.png +0 -0
- package/logo.jpeg +0 -0
- package/package.json +27 -0
- package/src/cli.ts +259 -0
- package/src/clients/geminiClient.ts +168 -0
- package/src/config/constants.ts +1105 -0
- package/src/config/options.ts +15 -0
- package/src/config/templates.ts +4 -0
- package/src/config/tiredGirl.ts +11 -0
- package/src/image/annotate.ts +139 -0
- package/src/image/grid.ts +58 -0
- package/src/index.ts +27 -0
- package/src/pipelines/createMuse.ts +76 -0
- package/src/pipelines/generateImages.ts +203 -0
- package/src/pipelines/generateTiredGirl.ts +86 -0
- package/src/pipelines/generateVideo.ts +36 -0
- package/src/pipelines/refineImage.ts +36 -0
- package/src/pipelines/resolve.ts +88 -0
- package/src/pipelines/upscaleImage.ts +30 -0
- package/src/prompt/buildPrompt.ts +380 -0
- package/src/prompt/tiredGirlPrompt.ts +35 -0
- package/src/storage/files.ts +41 -0
- package/src/storage/museStore.ts +31 -0
- package/src/types.ts +198 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ETHNICITIES,
|
|
3
|
+
ETHNICITY_REGIONS,
|
|
4
|
+
SKIN_TONES,
|
|
5
|
+
HAIR_COLORS,
|
|
6
|
+
BACKGROUNDS,
|
|
7
|
+
BACKGROUND_TYPES,
|
|
8
|
+
VIBES,
|
|
9
|
+
RESOLUTIONS,
|
|
10
|
+
OCCASIONS,
|
|
11
|
+
getRandomLuxuryInterior,
|
|
12
|
+
getStylePoolForTemplate,
|
|
13
|
+
isMuseEnabledTemplate,
|
|
14
|
+
isVideoEnabledTemplate
|
|
15
|
+
} from "./constants.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Option } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export const TIRED_GIRL_STYLES: Option[] = [
|
|
4
|
+
{ id: "tired", name: "Tired", value: "slightly tired, soft under-eye shadows, relaxed expression, end-of-day fatigue" },
|
|
5
|
+
{ id: "morning", name: "Morning", value: "freshly awake, gentle morning light, calm sleepy energy" },
|
|
6
|
+
{ id: "no_makeup", name: "No Makeup", value: "completely bare faced, no makeup, natural skin texture" },
|
|
7
|
+
{ id: "crazy_hair", name: "Crazy Hair", value: "messy, unstyled hair, a bit wild and uncombed" },
|
|
8
|
+
{ id: "pyjama", name: "Pyjama", value: "cozy pajamas or loungewear, relaxed home vibe" }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const TIRED_GIRL_STYLE_MAP = new Map(TIRED_GIRL_STYLES.map((s) => [s.id, s]));
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { TextOverlayOptions, TextPosition } from "../types.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FONT = "Montserrat";
|
|
5
|
+
|
|
6
|
+
const getTextAnchor = (position: TextPosition): { anchor: string; align: string } => {
|
|
7
|
+
if (position.endsWith("left")) return { anchor: "start", align: "start" };
|
|
8
|
+
if (position.endsWith("right")) return { anchor: "end", align: "end" };
|
|
9
|
+
return { anchor: "middle", align: "middle" };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const wrapText = (text: string, maxChars: number): string[] => {
|
|
13
|
+
if (maxChars <= 0) return [text];
|
|
14
|
+
const words = text.split(/\s+/);
|
|
15
|
+
const lines: string[] = [];
|
|
16
|
+
let line = "";
|
|
17
|
+
for (const word of words) {
|
|
18
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
19
|
+
if (candidate.length > maxChars) {
|
|
20
|
+
if (line) lines.push(line);
|
|
21
|
+
line = word;
|
|
22
|
+
} else {
|
|
23
|
+
line = candidate;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (line) lines.push(line);
|
|
27
|
+
return lines;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const positionCoords = (
|
|
31
|
+
position: TextPosition,
|
|
32
|
+
width: number,
|
|
33
|
+
height: number,
|
|
34
|
+
padding: number,
|
|
35
|
+
extraOffset: number
|
|
36
|
+
): { x: number; y: number; baseline: string } => {
|
|
37
|
+
switch (position) {
|
|
38
|
+
case "top-left":
|
|
39
|
+
return { x: padding, y: padding, baseline: "hanging" };
|
|
40
|
+
case "top-right":
|
|
41
|
+
return { x: width - padding, y: padding, baseline: "hanging" };
|
|
42
|
+
case "bottom-left":
|
|
43
|
+
return { x: padding, y: height - padding - extraOffset, baseline: "baseline" };
|
|
44
|
+
case "bottom-right":
|
|
45
|
+
return { x: width - padding, y: height - padding - extraOffset, baseline: "baseline" };
|
|
46
|
+
case "bottom-center":
|
|
47
|
+
return { x: width / 2, y: height - padding - extraOffset, baseline: "baseline" };
|
|
48
|
+
case "bottom-center-high":
|
|
49
|
+
return { x: width / 2, y: height - padding - extraOffset, baseline: "baseline" };
|
|
50
|
+
case "center":
|
|
51
|
+
return { x: width / 2, y: height / 2, baseline: "middle" };
|
|
52
|
+
case "top-center":
|
|
53
|
+
default:
|
|
54
|
+
return { x: width / 2, y: padding, baseline: "hanging" };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const annotateImage = async (
|
|
59
|
+
inputPath: string,
|
|
60
|
+
outputPath: string,
|
|
61
|
+
text: string,
|
|
62
|
+
options: TextOverlayOptions = {}
|
|
63
|
+
): Promise<void> => {
|
|
64
|
+
const image = sharp(inputPath);
|
|
65
|
+
const metadata = await image.metadata();
|
|
66
|
+
const width = metadata.width ?? 0;
|
|
67
|
+
const height = metadata.height ?? 0;
|
|
68
|
+
if (!width || !height) {
|
|
69
|
+
throw new Error("Unable to read image dimensions for annotation.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fontFamily = options.fontFamily ?? DEFAULT_FONT;
|
|
73
|
+
const fontSize = options.fontSize ?? Math.round(Math.min(width, height) * 0.05);
|
|
74
|
+
const fontWeight = options.fontWeight ?? 700;
|
|
75
|
+
const color = options.color ?? "#FFFFFF";
|
|
76
|
+
const strokeColor = options.strokeColor ?? "rgba(0,0,0,0.6)";
|
|
77
|
+
const strokeWidth = options.strokeWidth ?? Math.max(2, Math.round(fontSize * 0.08));
|
|
78
|
+
const banner = options.banner ?? false;
|
|
79
|
+
const bannerColor = options.bannerColor ?? "rgba(0,0,0,0.45)";
|
|
80
|
+
const bannerPadding = options.bannerPadding ?? Math.round(fontSize * 0.6);
|
|
81
|
+
const bannerRadius = options.bannerRadius ?? Math.round(fontSize * 0.4);
|
|
82
|
+
const position = options.position ?? "top-center";
|
|
83
|
+
const padding = options.padding ?? Math.round(fontSize * 0.8);
|
|
84
|
+
const maxWidthRatio = options.maxWidthRatio ?? 0.85;
|
|
85
|
+
|
|
86
|
+
const maxChars = Math.floor((width * maxWidthRatio) / (fontSize * 0.6));
|
|
87
|
+
const lines = wrapText(text, maxChars);
|
|
88
|
+
const lineHeight = Math.round(fontSize * 1.2);
|
|
89
|
+
|
|
90
|
+
const extraOffset = position === "bottom-center-high" ? Math.round(fontSize * 1.25) : 0;
|
|
91
|
+
let { x, y, baseline } = positionCoords(position, width, height, padding, extraOffset);
|
|
92
|
+
const { anchor } = getTextAnchor(position);
|
|
93
|
+
|
|
94
|
+
const svgLines = lines
|
|
95
|
+
.map((line, index) => {
|
|
96
|
+
const dy = index === 0 ? 0 : lineHeight;
|
|
97
|
+
return `<tspan x="${x}" dy="${dy}">${line}</tspan>`;
|
|
98
|
+
})
|
|
99
|
+
.join("");
|
|
100
|
+
|
|
101
|
+
const totalTextHeight = lineHeight * lines.length;
|
|
102
|
+
const textBlockWidth = Math.min(width * maxWidthRatio, width - padding * 2);
|
|
103
|
+
const bannerWidth = textBlockWidth + bannerPadding * 2;
|
|
104
|
+
const bannerHeight = totalTextHeight + bannerPadding * 2;
|
|
105
|
+
const bannerX = position.endsWith("left")
|
|
106
|
+
? padding
|
|
107
|
+
: position.endsWith("right")
|
|
108
|
+
? width - padding - bannerWidth
|
|
109
|
+
: (width - bannerWidth) / 2;
|
|
110
|
+
const bannerY =
|
|
111
|
+
position.startsWith("bottom")
|
|
112
|
+
? height - padding - bannerHeight - extraOffset
|
|
113
|
+
: position === "center"
|
|
114
|
+
? (height - bannerHeight) / 2
|
|
115
|
+
: padding;
|
|
116
|
+
|
|
117
|
+
const bannerRect = banner
|
|
118
|
+
? `<rect x="${bannerX}" y="${bannerY}" width="${bannerWidth}" height="${bannerHeight}" rx="${bannerRadius}" ry="${bannerRadius}" fill="${bannerColor}" />`
|
|
119
|
+
: "";
|
|
120
|
+
|
|
121
|
+
if (banner) {
|
|
122
|
+
x = bannerX + bannerWidth / 2;
|
|
123
|
+
y = bannerY + bannerHeight / 2 - (totalTextHeight - lineHeight) / 2;
|
|
124
|
+
baseline = "middle";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const svg = `
|
|
128
|
+
<svg width="${width}" height="${height}">
|
|
129
|
+
<style>
|
|
130
|
+
.title { font-family: '${fontFamily}', 'Poppins', 'Arial Black', Arial, sans-serif; font-size: ${fontSize}px; font-weight: ${fontWeight}; fill: ${color}; text-anchor: ${anchor}; paint-order: stroke fill; stroke: ${strokeColor}; stroke-width: ${strokeWidth}px; }
|
|
131
|
+
</style>
|
|
132
|
+
${bannerRect}
|
|
133
|
+
<text x="${x}" y="${y}" dominant-baseline="${baseline}" class="title">${svgLines}</text>
|
|
134
|
+
</svg>`;
|
|
135
|
+
|
|
136
|
+
await sharp(inputPath)
|
|
137
|
+
.composite([{ input: Buffer.from(svg), top: 0, left: 0 }])
|
|
138
|
+
.toFile(outputPath);
|
|
139
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GridOptions } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export const createImageGrid = async (
|
|
6
|
+
inputPaths: string[],
|
|
7
|
+
outputPath: string,
|
|
8
|
+
options: GridOptions
|
|
9
|
+
): Promise<void> => {
|
|
10
|
+
const columns = options.columns;
|
|
11
|
+
const rows = options.rows;
|
|
12
|
+
if (columns <= 0 || rows <= 0) throw new Error("Grid columns/rows must be positive.");
|
|
13
|
+
|
|
14
|
+
const padding = options.padding ?? 20;
|
|
15
|
+
const background = options.background ?? "#000000";
|
|
16
|
+
|
|
17
|
+
const metas = await Promise.all(inputPaths.map((p) => sharp(p).metadata()));
|
|
18
|
+
const widths = metas.map((m) => m.width ?? 0);
|
|
19
|
+
const heights = metas.map((m) => m.height ?? 0);
|
|
20
|
+
|
|
21
|
+
const tileWidth = options.tileWidth ?? Math.max(...widths);
|
|
22
|
+
const tileHeight = options.tileHeight ?? Math.max(...heights);
|
|
23
|
+
|
|
24
|
+
const canvasWidth = columns * tileWidth + (columns + 1) * padding;
|
|
25
|
+
const canvasHeight = rows * tileHeight + (rows + 1) * padding;
|
|
26
|
+
|
|
27
|
+
const composites: sharp.OverlayOptions[] = [];
|
|
28
|
+
|
|
29
|
+
for (let index = 0; index < rows * columns; index += 1) {
|
|
30
|
+
const input = inputPaths[index];
|
|
31
|
+
if (!input) break;
|
|
32
|
+
const row = Math.floor(index / columns);
|
|
33
|
+
const col = index % columns;
|
|
34
|
+
|
|
35
|
+
const left = padding + col * (tileWidth + padding);
|
|
36
|
+
const top = padding + row * (tileHeight + padding);
|
|
37
|
+
|
|
38
|
+
const resized = await sharp(input)
|
|
39
|
+
.resize(tileWidth, tileHeight, {
|
|
40
|
+
fit: "contain",
|
|
41
|
+
background
|
|
42
|
+
})
|
|
43
|
+
.toBuffer();
|
|
44
|
+
|
|
45
|
+
composites.push({ input: resized, top, left });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await sharp({
|
|
49
|
+
create: {
|
|
50
|
+
width: canvasWidth,
|
|
51
|
+
height: canvasHeight,
|
|
52
|
+
channels: 3,
|
|
53
|
+
background
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.composite(composites)
|
|
57
|
+
.toFile(outputPath);
|
|
58
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { generateImages } from "./pipelines/generateImages.js";
|
|
3
|
+
export { refineImage } from "./pipelines/refineImage.js";
|
|
4
|
+
export { upscaleImage } from "./pipelines/upscaleImage.js";
|
|
5
|
+
export { generateTiredGirl } from "./pipelines/generateTiredGirl.js";
|
|
6
|
+
export { createMuse, generateMuseVariations } from "./pipelines/createMuse.js";
|
|
7
|
+
export { generateVideo } from "./pipelines/generateVideo.js";
|
|
8
|
+
export { annotateImage } from "./image/annotate.js";
|
|
9
|
+
export { createImageGrid } from "./image/grid.js";
|
|
10
|
+
export { TEMPLATES, TEMPLATE_MAP } from "./config/templates.js";
|
|
11
|
+
export {
|
|
12
|
+
ETHNICITIES,
|
|
13
|
+
ETHNICITY_REGIONS,
|
|
14
|
+
SKIN_TONES,
|
|
15
|
+
HAIR_COLORS,
|
|
16
|
+
BACKGROUNDS,
|
|
17
|
+
BACKGROUND_TYPES,
|
|
18
|
+
VIBES,
|
|
19
|
+
RESOLUTIONS,
|
|
20
|
+
OCCASIONS,
|
|
21
|
+
getRandomLuxuryInterior,
|
|
22
|
+
getStylePoolForTemplate,
|
|
23
|
+
isMuseEnabledTemplate,
|
|
24
|
+
isVideoEnabledTemplate
|
|
25
|
+
} from "./config/options.js";
|
|
26
|
+
export { TIRED_GIRL_STYLES, TIRED_GIRL_STYLE_MAP } from "./config/tiredGirl.js";
|
|
27
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { GeminiClient } from "../clients/geminiClient.js";
|
|
3
|
+
import { LumiereConfig, CreateMuseRequest, CreateMuseResult, MuseRecord } from "../types.js";
|
|
4
|
+
import { readImageAsPart, resolveOutputDir, saveBase64Image, writeJson } from "../storage/files.js";
|
|
5
|
+
import { addMuse } from "../storage/museStore.js";
|
|
6
|
+
|
|
7
|
+
const STYLE_HINTS = [
|
|
8
|
+
"Change pose and posture dramatically.",
|
|
9
|
+
"Change hair styling and wardrobe while keeping the same person.",
|
|
10
|
+
"Use a different simple studio background.",
|
|
11
|
+
"Shift lighting direction for a new mood.",
|
|
12
|
+
"Vary the camera angle slightly for a new perspective."
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const variationPrompt = (styleHint: string) => `Generate exactly one chest-up portrait of this person.\n\nRules:\n- Output a single image only. No collages or grids.\n- Keep the exact same person (face, features, ethnicity, skin tone).\n- Change pose, hair, and wardrobe. ${styleHint}\n- Use a new simple background.\n- Do NOT include the jewelry from the source image. Use no jewelry or different jewelry only.\n`;
|
|
16
|
+
|
|
17
|
+
export const generateMuseVariations = async (
|
|
18
|
+
config: LumiereConfig,
|
|
19
|
+
sourceImage: string,
|
|
20
|
+
count: number,
|
|
21
|
+
outputDir: string
|
|
22
|
+
): Promise<string[]> => {
|
|
23
|
+
const client = new GeminiClient(config);
|
|
24
|
+
const imagePart = await readImageAsPart(sourceImage);
|
|
25
|
+
const outputs: string[] = [];
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < count; i += 1) {
|
|
28
|
+
const hint = STYLE_HINTS[i % STYLE_HINTS.length];
|
|
29
|
+
const images = await client.generateImage({
|
|
30
|
+
parts: [{ text: variationPrompt(hint) }, imagePart],
|
|
31
|
+
aspectRatio: "3:4",
|
|
32
|
+
imageSize: "2K"
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const outputPath = path.join(outputDir, `variation_${i + 1}.png`);
|
|
36
|
+
await saveBase64Image(images[0], outputPath);
|
|
37
|
+
outputs.push(outputPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return outputs;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const createMuse = async (
|
|
44
|
+
config: LumiereConfig,
|
|
45
|
+
request: CreateMuseRequest
|
|
46
|
+
): Promise<CreateMuseResult> => {
|
|
47
|
+
const outputDir = resolveOutputDir(request.outputDir ?? config.outputDir, "muses");
|
|
48
|
+
const variationCount = request.variations ?? 3;
|
|
49
|
+
|
|
50
|
+
const variationPaths = await generateMuseVariations(
|
|
51
|
+
config,
|
|
52
|
+
request.sourceImage,
|
|
53
|
+
variationCount,
|
|
54
|
+
outputDir
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const museImages = variationPaths.slice(0, 3);
|
|
58
|
+
if (museImages.length < 3) {
|
|
59
|
+
throw new Error("Muse creation requires at least 3 variations.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const muse: MuseRecord = {
|
|
63
|
+
id: `muse_${Date.now()}`,
|
|
64
|
+
name: request.name,
|
|
65
|
+
imagePaths: museImages,
|
|
66
|
+
createdAt: new Date().toISOString()
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await addMuse(request.outputDir ?? config.outputDir, muse);
|
|
70
|
+
|
|
71
|
+
const logPath = path.join(outputDir, "muse.json");
|
|
72
|
+
await writeJson(logPath, { muse, variationPaths });
|
|
73
|
+
|
|
74
|
+
return { muse, variationPaths, logPath };
|
|
75
|
+
};
|
|
76
|
+
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { GeminiClient } from "../clients/geminiClient.js";
|
|
3
|
+
import { LumiereConfig, GenerationRequest, GenerationResult } from "../types.js";
|
|
4
|
+
import { buildLLMPrompt, buildSystemInstruction } from "../prompt/buildPrompt.js";
|
|
5
|
+
import { readImageAsPart, resolveOutputDir, saveBase64Image, writeJson } from "../storage/files.js";
|
|
6
|
+
import { getMuseById } from "../storage/museStore.js";
|
|
7
|
+
import { getStylePoolForTemplate } from "../config/constants.js";
|
|
8
|
+
import {
|
|
9
|
+
getTemplate,
|
|
10
|
+
resolveBackground,
|
|
11
|
+
resolveBackgroundType,
|
|
12
|
+
resolveEthnicity,
|
|
13
|
+
resolveHairColor,
|
|
14
|
+
resolveOccasion,
|
|
15
|
+
resolveResolution,
|
|
16
|
+
resolveSkinTone,
|
|
17
|
+
resolveTemplateOption,
|
|
18
|
+
resolveVibe
|
|
19
|
+
} from "./resolve.js";
|
|
20
|
+
|
|
21
|
+
export const generateImages = async (
|
|
22
|
+
config: LumiereConfig,
|
|
23
|
+
request: GenerationRequest
|
|
24
|
+
): Promise<GenerationResult> => {
|
|
25
|
+
const client = new GeminiClient(config);
|
|
26
|
+
const outputDir = resolveOutputDir(request.outputDir ?? config.outputDir, "generations");
|
|
27
|
+
|
|
28
|
+
const template = getTemplate(request.selections.templateId);
|
|
29
|
+
const detail = resolveTemplateOption(template, request.selections.detailId, "primary");
|
|
30
|
+
const secondaryDetail = resolveTemplateOption(template, request.selections.secondaryDetailId, "secondary");
|
|
31
|
+
const tertiaryDetail = resolveTemplateOption(template, request.selections.tertiaryDetailId, "tertiary");
|
|
32
|
+
const ethnicity = resolveEthnicity(request.selections.ethnicityId);
|
|
33
|
+
const skinTone = resolveSkinTone(request.selections.skinToneId);
|
|
34
|
+
const hairColor = resolveHairColor(request.selections.hairColorId);
|
|
35
|
+
const background = resolveBackground(request.selections.backgroundId);
|
|
36
|
+
const backgroundType = resolveBackgroundType(request.selections.backgroundTypeId);
|
|
37
|
+
const vibe = resolveVibe(request.selections.vibeId);
|
|
38
|
+
const resolution = resolveResolution(request.selections.resolutionId);
|
|
39
|
+
const occasion = resolveOccasion(request.selections.occasionId);
|
|
40
|
+
|
|
41
|
+
const quantity = request.quantity ?? 1;
|
|
42
|
+
const imageParts = await Promise.all(request.inputImages.map((file: string) => readImageAsPart(file)));
|
|
43
|
+
|
|
44
|
+
let museImageParts: Array<{ inlineData: { mimeType: string; data: string } }> | undefined;
|
|
45
|
+
let museId = request.museId;
|
|
46
|
+
|
|
47
|
+
if (request.museImagePaths && request.museImagePaths.length > 0) {
|
|
48
|
+
museImageParts = await Promise.all(request.museImagePaths.map((file: string) => readImageAsPart(file)));
|
|
49
|
+
} else if (museId) {
|
|
50
|
+
const muse = await getMuseById(request.outputDir ?? config.outputDir, museId);
|
|
51
|
+
if (!muse) throw new Error(`Muse not found: ${museId}`);
|
|
52
|
+
museImageParts = await Promise.all(muse.imagePaths.map((file: string) => readImageAsPart(file)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isFloating = template.id === "floating_minimal";
|
|
56
|
+
const isFlatlay = template.id === "flatlay_creative";
|
|
57
|
+
const isHandModel = template.id === "hand_model";
|
|
58
|
+
const isMuseumExhibit = template.id === "museum_exhibit";
|
|
59
|
+
const isRomanticMood = template.id === "romantic_mood";
|
|
60
|
+
const skipEthnicity = !!museImageParts || isHandModel || isFloating || isFlatlay || isMuseumExhibit || isRomanticMood;
|
|
61
|
+
|
|
62
|
+
const stylePool = getStylePoolForTemplate(template.id);
|
|
63
|
+
const shuffledHints = stylePool.length > 0 ? [...stylePool].sort(() => 0.5 - Math.random()) : [];
|
|
64
|
+
|
|
65
|
+
const basePrompt = buildLLMPrompt(
|
|
66
|
+
template,
|
|
67
|
+
detail,
|
|
68
|
+
secondaryDetail,
|
|
69
|
+
tertiaryDetail,
|
|
70
|
+
skipEthnicity ? undefined : ethnicity,
|
|
71
|
+
skinTone,
|
|
72
|
+
hairColor,
|
|
73
|
+
background,
|
|
74
|
+
backgroundType,
|
|
75
|
+
vibe,
|
|
76
|
+
request.inputImages.length,
|
|
77
|
+
occasion,
|
|
78
|
+
false,
|
|
79
|
+
undefined
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const systemInstruction = buildSystemInstruction(template, ethnicity);
|
|
83
|
+
|
|
84
|
+
const outputs: string[] = [];
|
|
85
|
+
let enhancedPrompt = "";
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < quantity; i += 1) {
|
|
88
|
+
const styleHint = request.styleHint ?? (shuffledHints.length > 0 ? shuffledHints[i % shuffledHints.length] : undefined);
|
|
89
|
+
const promptForImage = buildLLMPrompt(
|
|
90
|
+
template,
|
|
91
|
+
detail,
|
|
92
|
+
secondaryDetail,
|
|
93
|
+
tertiaryDetail,
|
|
94
|
+
skipEthnicity ? undefined : ethnicity,
|
|
95
|
+
skinTone,
|
|
96
|
+
hairColor,
|
|
97
|
+
background,
|
|
98
|
+
backgroundType,
|
|
99
|
+
vibe,
|
|
100
|
+
request.inputImages.length,
|
|
101
|
+
occasion,
|
|
102
|
+
false,
|
|
103
|
+
styleHint
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
let perImagePrompt = promptForImage;
|
|
107
|
+
if (request.enhancePrompt ?? true) {
|
|
108
|
+
try {
|
|
109
|
+
const enhanceParts = [
|
|
110
|
+
...imageParts.map((part) => ({
|
|
111
|
+
inlineData: {
|
|
112
|
+
data: part.inlineData.data,
|
|
113
|
+
mimeType: part.inlineData.mimeType
|
|
114
|
+
}
|
|
115
|
+
})),
|
|
116
|
+
{ text: promptForImage }
|
|
117
|
+
];
|
|
118
|
+
perImagePrompt = await client.generateEnhancedPrompt({
|
|
119
|
+
parts: enhanceParts,
|
|
120
|
+
systemInstruction
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
const ethnicityFallback = (ethnicity && ethnicity.id !== "none") ? ethnicity.value : "Professional female model";
|
|
124
|
+
perImagePrompt = `${template.basePrompt}. ${ethnicityFallback}, shot on Kodak Portra 400, soft film grain, natural light, peach fuzz. Wearing the specific jewelry item shown. No fake sparkles.`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
perImagePrompt = `${perImagePrompt} Ensure authenticity and high-end editorial aesthetic. IMPORTANT: Add slight film grain and micro-contrast.`;
|
|
129
|
+
|
|
130
|
+
if (museImageParts) {
|
|
131
|
+
perImagePrompt = `
|
|
132
|
+
${perImagePrompt}
|
|
133
|
+
|
|
134
|
+
CRITICAL MODEL CONSISTENCY REQUIREMENT:
|
|
135
|
+
The last 3 images provided are reference images of a specific model (the "Muse").
|
|
136
|
+
The model in the generated image MUST match this Muse reference exactly.
|
|
137
|
+
|
|
138
|
+
**STRICT FORMATTING RULE**:
|
|
139
|
+
- Output exactly ONE single, unified photograph.
|
|
140
|
+
- ABSOLUTELY NO collages, NO grids, and NO split screens.
|
|
141
|
+
- Even though 3 reference images are provided, the final result must be a single shot of the person wearing the jewelry.
|
|
142
|
+
|
|
143
|
+
Use the Muse reference images to ensure consistency in:
|
|
144
|
+
- Exact facial features and structure
|
|
145
|
+
- Skin tone and texture
|
|
146
|
+
- Overall appearance and aesthetic
|
|
147
|
+
- The model should look like the SAME PERSON as in the Muse reference images
|
|
148
|
+
|
|
149
|
+
This is essential for brand consistency - the generated model must be recognizable
|
|
150
|
+
as the same person shown in the Muse reference images.
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = [
|
|
155
|
+
...imageParts.map((part) => ({
|
|
156
|
+
inlineData: {
|
|
157
|
+
data: part.inlineData.data,
|
|
158
|
+
mimeType: part.inlineData.mimeType
|
|
159
|
+
}
|
|
160
|
+
}))
|
|
161
|
+
];
|
|
162
|
+
if (museImageParts) {
|
|
163
|
+
parts.push(
|
|
164
|
+
...museImageParts.map((part) => ({
|
|
165
|
+
inlineData: {
|
|
166
|
+
data: part.inlineData.data,
|
|
167
|
+
mimeType: part.inlineData.mimeType
|
|
168
|
+
}
|
|
169
|
+
}))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
parts.push({ text: perImagePrompt });
|
|
173
|
+
const images = await client.generateImage({
|
|
174
|
+
parts,
|
|
175
|
+
aspectRatio: resolution.aspectRatio,
|
|
176
|
+
imageSize: "2K"
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const imagePath = path.join(outputDir, `image_${i + 1}.png`);
|
|
180
|
+
await saveBase64Image(images[0], imagePath);
|
|
181
|
+
outputs.push(imagePath);
|
|
182
|
+
|
|
183
|
+
enhancedPrompt = perImagePrompt;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const logPath = path.join(outputDir, "generation.json");
|
|
187
|
+
await writeJson(logPath, {
|
|
188
|
+
template: template.id,
|
|
189
|
+
selections: request.selections,
|
|
190
|
+
quantity,
|
|
191
|
+
prompt: basePrompt,
|
|
192
|
+
enhancedPrompt,
|
|
193
|
+
outputs
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
prompt: basePrompt,
|
|
198
|
+
enhancedPrompt,
|
|
199
|
+
outputImages: outputs,
|
|
200
|
+
logPath
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { GeminiClient } from "../clients/geminiClient.js";
|
|
3
|
+
import { LumiereConfig, TiredGirlRequest, TiredGirlResult } from "../types.js";
|
|
4
|
+
import { readImageAsPart, resolveOutputDir, saveBase64Image, writeJson } from "../storage/files.js";
|
|
5
|
+
import { getMuseById } from "../storage/museStore.js";
|
|
6
|
+
import { resolveTiredGirlStyles, buildTiredGirlPrompt } from "../prompt/tiredGirlPrompt.js";
|
|
7
|
+
|
|
8
|
+
export const generateTiredGirl = async (
|
|
9
|
+
config: LumiereConfig,
|
|
10
|
+
request: TiredGirlRequest
|
|
11
|
+
): Promise<TiredGirlResult> => {
|
|
12
|
+
const client = new GeminiClient(config);
|
|
13
|
+
const outputDir = resolveOutputDir(request.outputDir ?? config.outputDir, "tired_girl");
|
|
14
|
+
|
|
15
|
+
let referenceParts: Array<{ inlineData: { data: string; mimeType: string } }> = [];
|
|
16
|
+
let hasMuse = false;
|
|
17
|
+
|
|
18
|
+
if (request.museId) {
|
|
19
|
+
const muse = await getMuseById(request.outputDir ?? config.outputDir, request.museId);
|
|
20
|
+
if (!muse) throw new Error(`Muse not found: ${request.museId}`);
|
|
21
|
+
referenceParts = await Promise.all(muse.imagePaths.map((file) => readImageAsPart(file)));
|
|
22
|
+
hasMuse = true;
|
|
23
|
+
} else if (request.inputImage) {
|
|
24
|
+
referenceParts = [await readImageAsPart(request.inputImage)];
|
|
25
|
+
} else {
|
|
26
|
+
throw new Error("Either museId or inputImage is required for tired_girl generation.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const quantity = request.quantity ?? 1;
|
|
30
|
+
const styles = resolveTiredGirlStyles(request.styleIds, quantity);
|
|
31
|
+
|
|
32
|
+
const outputs: string[] = [];
|
|
33
|
+
const prompts: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < quantity; i += 1) {
|
|
36
|
+
const style = styles[i % styles.length];
|
|
37
|
+
const userPrompt = buildTiredGirlPrompt(style, hasMuse);
|
|
38
|
+
|
|
39
|
+
const enhanceParts = [
|
|
40
|
+
...referenceParts.map((part) => ({
|
|
41
|
+
inlineData: {
|
|
42
|
+
data: part.inlineData.data,
|
|
43
|
+
mimeType: part.inlineData.mimeType
|
|
44
|
+
}
|
|
45
|
+
})),
|
|
46
|
+
{ text: userPrompt }
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const enhanced = await client.generateEnhancedPrompt({
|
|
50
|
+
parts: enhanceParts,
|
|
51
|
+
systemInstruction: "You are a world-class portrait photographer. Follow all jewelry removal constraints strictly."
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const generationParts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = [
|
|
55
|
+
...referenceParts.map((part) => ({
|
|
56
|
+
inlineData: {
|
|
57
|
+
data: part.inlineData.data,
|
|
58
|
+
mimeType: part.inlineData.mimeType
|
|
59
|
+
}
|
|
60
|
+
})),
|
|
61
|
+
{ text: enhanced }
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const images = await client.generateImage({
|
|
65
|
+
parts: generationParts,
|
|
66
|
+
imageSize: "2K"
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const imagePath = path.join(outputDir, `tired_girl_${i + 1}.png`);
|
|
70
|
+
await saveBase64Image(images[0], imagePath);
|
|
71
|
+
outputs.push(imagePath);
|
|
72
|
+
prompts.push(enhanced);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const logPath = path.join(outputDir, "tired_girl.json");
|
|
76
|
+
await writeJson(logPath, {
|
|
77
|
+
museId: request.museId,
|
|
78
|
+
inputImage: request.inputImage,
|
|
79
|
+
styles: styles.map((s) => s.id),
|
|
80
|
+
quantity,
|
|
81
|
+
prompts,
|
|
82
|
+
outputs
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { outputImages: outputs, logPath };
|
|
86
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { GeminiClient } from "../clients/geminiClient.js";
|
|
3
|
+
import { LumiereConfig, VideoRequest, VideoResult } from "../types.js";
|
|
4
|
+
import { resolveOutputDir, saveBinary, writeJson } from "../storage/files.js";
|
|
5
|
+
|
|
6
|
+
export const generateVideo = async (
|
|
7
|
+
config: LumiereConfig,
|
|
8
|
+
request: VideoRequest
|
|
9
|
+
): Promise<VideoResult> => {
|
|
10
|
+
const client = new GeminiClient(config);
|
|
11
|
+
const outputDir = resolveOutputDir(request.outputDir ?? config.outputDir, "videos");
|
|
12
|
+
|
|
13
|
+
const result = await client.generateVideo({
|
|
14
|
+
prompt: request.prompt,
|
|
15
|
+
aspectRatio: request.aspectRatio,
|
|
16
|
+
durationSeconds: request.durationSeconds
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let videoPath: string | undefined;
|
|
20
|
+
if (result.videoBytes) {
|
|
21
|
+
videoPath = path.join(outputDir, "video.mp4");
|
|
22
|
+
await saveBinary(result.videoBytes, videoPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const logPath = path.join(outputDir, "video.json");
|
|
26
|
+
await writeJson(logPath, {
|
|
27
|
+
prompt: request.prompt,
|
|
28
|
+
aspectRatio: request.aspectRatio,
|
|
29
|
+
durationSeconds: request.durationSeconds,
|
|
30
|
+
operationName: result.operationName,
|
|
31
|
+
videoPath
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return { operationName: result.operationName, videoPath, logPath };
|
|
35
|
+
};
|
|
36
|
+
|