fix broken automatic metadata parsing

This commit is contained in:
Briant Diehl
2026-02-26 11:22:53 -07:00
parent 759bf86b07
commit 1a7f89df98
8 changed files with 144 additions and 18 deletions
@@ -152,8 +152,6 @@ export function VideoGenerationForm({ engine }: { engine: OrchestratorEngine2 })
graphInput.model = model;
graphInput.resources = splitResources;
console.log({ graphInput });
// Clone generation graph, initialize with mapped data, and validate
const graph = generationGraph.clone();
const externalCtx = buildGraphContext(status);
@@ -169,8 +167,6 @@ export function VideoGenerationForm({ engine }: { engine: OrchestratorEngine2 })
const graphData = result.data;
console.log({ graphData });
conditionalPerformTransaction(cost, () => {
mutate({
input: graphData,
@@ -363,7 +359,6 @@ function SubmitButton2({
: [];
const { model, resources: splitResources } = splitResourcesByType(parsedResources);
console.log({ parsedResources, model, splitResources });
graphInput.model = model;
graphInput.resources = splitResources;
@@ -34,7 +34,7 @@ import { usePromptFocusedStore } from '../inputs/PromptInput';
* Changes to these fields will NOT trigger a new whatIf query.
* Note: 'prompt' IS included because it affects SFW/NSFW classification and pricing.
*/
const IGNORED_KEYS_FOR_WHATIF = ['negativePrompt', 'seed', 'denoise'] as const;
const IGNORED_KEYS_FOR_WHATIF = ['negativePrompt', 'seed'] as const;
// =============================================================================
// Helper Functions
@@ -11,6 +11,8 @@ import { flux2KleinSampleMethods } from '~/shared/orchestrator/ImageGen/flux2-kl
import { zImageSampleMethods } from '~/shared/orchestrator/ImageGen/zImage.config';
import { defaultCatch } from '~/utils/zod-helpers';
const schedulers = ['simple', 'discrete', 'karras', 'exponential', 'ays'] as const;
// All valid samplers: UI samplers + sdcpp samplers for ZImageBase/Flux2Klein
const allValidSamplers = [
...generationSamplers,
@@ -35,7 +37,12 @@ export const textToImageParamsSchema = z.object({
sampler: z.string().refine((val) => allValidSamplers.includes(val as any), {
error: 'Invalid sampler',
}),
scheduler: z.enum(['simple', 'discrete', 'karras', 'exponential', 'ays']).optional(),
scheduler: z
.string()
.transform((val) =>
['simple', 'discrete', 'karras', 'exponential', 'ays'].includes(val) ? val : 'simple'
)
.optional(),
seed: z.coerce.number().min(1).max(generation.maxValues.seed).nullish().catch(null),
clipSkip: z.coerce.number().optional(),
steps: z.coerce.number().min(1).max(100).optional(),
@@ -385,7 +385,7 @@ export const klingGraph = new DataGraph<KlingCtx, GenerationCtx>()
.merge(
createCheckpointGraph({
versions: { options: klingVersionOptions },
defaultModelId: klingVersionIds.v3,
defaultModelId: klingVersionIds.v2_5_turbo, // Default to V2.5 Turbo for best performance/quality balance
})
)
@@ -111,11 +111,11 @@ export const stableDiffusionGraph = new DataGraph<
sliderNode({
min: 10,
max: 50,
defaultValue: 25,
defaultValue: 30,
presets: [
{ label: 'Fast', value: 15 },
{ label: 'Balanced', value: 25 },
{ label: 'High', value: 35 },
{ label: 'Fast', value: 20 },
{ label: 'Balanced', value: 30 },
{ label: 'High', value: 40 },
],
})
)
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { ExifParser } from '~/utils/metadata';
import { automaticMetadataProcessor } from '~/utils/metadata/automatic.metadata';
// TODO - create a suite of tests that uses images from civitai to test the different metadata parsers
const testImageUrl =
'https://orchestration-new.civitai.com/v2/consumer/blobs/E8S6FBPH50ENNVF2PD5XRBPXB0.jpeg?sig=CfDJ8N_qP_UguotCoWxV0GDJyrqO3glAlXP7D6Hb6iXbU9BAZ6no3Pzo0PfuPoI42wFTr-BalGCgmLC5CRJMpYU8Rpi_QqslJAMyMrcmNmeutDpUiZ-C8oAhuumEznFYdbOq3d1hQK4mW0qylzz7qmpPnTxZfJ67zOPQUvskp1Fwv_Xoh_1wKej0bllrUzLdAgpnOVnrb9LTQC8yjeYn2SRQPOFx5pYwna3qVSIXh1Oz-TN7yISHZEncedPwDrwyN91iwRqWujthjJHfyJ_ziykqJCdXmyi5mzp1fp5pzRTRjWOx&exp=2027-02-26T17:34:23.6860202Z';
// The actual generationDetails string extracted from the test image.
// The Civitai metadata contains nested objects (aspectRatio, resources array).
const realGenerationDetails = `an ancient warrior princess with sad face, from the side, looking up, in rain, a small stream of water running down over her face, high contrast shadowing,
candid style. high contrast, grain effect prominent throughout image, high contrast lighting creating dramatic shadows, grainy film-like texture, nipples
Negative prompt: photo , photography, bad quality, bad anatomy, worst quality, low quality, low resolution, extra fingers, blur, blurry, ugly, wrong proportions, watermark, image artifacts, lowres, ugly, jpeg artifacts, deformed, noisy image
Steps: 40, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 2027225909, Size: 1216x832, Clip skip: 2, Created Date: 2026-02-25T22:09:08.8166925Z, Civitai resources: [{"type":"checkpoint","modelVersionId":1714314,"modelName":"Plant Milk \\uD83C\\uDF3F - Model Suite","modelVersionName":"Hemp II"}], Civitai metadata: {"workflow":"txt2img","output":"image","input":"text","priority":"low","outputFormat":"jpeg","ecosystem":"Illustrious","quantity":4,"aspectRatio":{"value":"3:2","width":1216,"height":832},"negativePrompt":"photo , photography, bad quality, bad anatomy, worst quality, low quality, low resolution, extra fingers, blur, blurry, ugly, wrong proportions, watermark, image artifacts, lowres, ugly, jpeg artifacts, deformed, noisy image","sampler":"DPM++ 2M Karras","cfgScale":7,"steps":40,"clipSkip":2,"seed":2027225909,"enhancedCompatibility":false,"prompt":"an ancient warrior princess with sad face, from the side, looking up, in rain, a small stream of water running down over her face, high contrast shadowing,\\n candid style. high contrast, grain effect prominent throughout image, high contrast lighting creating dramatic shadows, grainy film-like texture, nipples","resources":[{"modelVersionId":1714314,"strength":1,"type":"Checkpoint"}]}`;
describe('ExifParser - test image URL', () => {
it('should parse metadata from the test image without error', async () => {
const parser = await ExifParser(testImageUrl);
const parsed = parser.parse();
expect(parsed).toBeDefined();
expect(parsed?.prompt).toContain('ancient warrior princess');
expect(parsed?.extra).toBeDefined();
expect(parsed?.extra).toHaveProperty('workflow', 'txt2img');
expect(parsed?.extra).toHaveProperty('ecosystem', 'Illustrious');
});
});
describe('automaticMetadataProcessor - Civitai metadata with nested JSON', () => {
it('parses Civitai metadata with nested objects from real image data', () => {
const exif = { generationDetails: realGenerationDetails, parameters: realGenerationDetails };
expect(automaticMetadataProcessor.canParse(exif)).toBe(true);
const result = automaticMetadataProcessor.parse(exif);
expect(result.extra).toBeDefined();
expect(result.extra).toHaveProperty('workflow', 'txt2img');
expect(result.extra).toHaveProperty('ecosystem', 'Illustrious');
expect(result.extra?.aspectRatio).toEqual({ value: '3:2', width: 1216, height: 832 });
expect(result.extra?.resources).toEqual([
{ modelVersionId: 1714314, strength: 1, type: 'Checkpoint' },
]);
});
it('parses Civitai metadata with nested objects (minimal case)', () => {
const metadata = `Steps: 20, Sampler: Euler, Civitai metadata: {"flat": "ok", "nested": {"inner": "value"}}`;
const exif = { generationDetails: metadata, parameters: metadata };
expect(automaticMetadataProcessor.canParse(exif)).toBe(true);
const result = automaticMetadataProcessor.parse(exif);
expect(result.extra).toEqual({ flat: 'ok', nested: { inner: 'value' } });
});
it('parses flat Civitai metadata', () => {
const metadata = `Steps: 20, Sampler: Euler, Civitai metadata: {"remixOfId": 123, "workflow": "txt2img"}`;
const exif = { generationDetails: metadata, parameters: metadata };
expect(automaticMetadataProcessor.canParse(exif)).toBe(true);
const result = automaticMetadataProcessor.parse(exif);
expect(result.extra).toEqual({ remixOfId: 123, workflow: 'txt2img' });
});
it('does not leave Civitai metadata fragments in other parsed fields', () => {
const metadata = `Steps: 20, Sampler: Euler, Size: 512x512, Civitai metadata: {"workflow": "txt2img", "nested": {"a": 1}}`;
const exif = { generationDetails: metadata, parameters: metadata };
const result = automaticMetadataProcessor.parse(exif);
expect(result.steps).toBe('20');
expect(result.sampler).toBe('Euler');
expect(result['Size']).toBe('512x512');
// Civitai metadata should be fully removed from details line, not leaking into other fields
expect(result['Civitai metadata']).toBeUndefined();
});
});
+51 -5
View File
@@ -16,7 +16,7 @@ type CivitaiResource = {
// #region [helpers]
const hashesRegex = /, Hashes:\s*({[^}]+})/;
const civitaiResources = /, Civitai resources:\s*(\[\{.*?\}\])/;
const civitaiMetadata = /, Civitai metadata:\s*(\{.*?\})/;
const civitaiMetadataPrefix = ', Civitai metadata: ';
const badExtensionKeys = ['Resources: ', 'Hashed prompt: ', 'Hashed Negative prompt: '];
const templateKeys = ['Template: ', 'Negative Template: '] as const;
const automaticExtraNetsRegex = /<(lora|hypernet):([a-zA-Z0-9_\.\-]+):([0-9.]+)>/g;
@@ -47,6 +47,48 @@ const excludedKeys = [
'other',
'external',
];
/** Extract a balanced JSON object from a string, handling nested braces. */
function extractBalancedJson(
str: string,
prefix: string
): { json: string; start: number; end: number } | null {
const prefixIndex = str.indexOf(prefix);
if (prefixIndex === -1) return null;
const jsonStart = str.indexOf('{', prefixIndex + prefix.length);
if (jsonStart === -1) return null;
let depth = 0;
let inString = false;
let escape = false;
for (let i = jsonStart; i < str.length; i++) {
const char = str[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{') depth++;
else if (char === '}') {
depth--;
if (depth === 0) {
return { json: str.substring(jsonStart, i + 1), start: prefixIndex, end: i + 1 };
}
}
}
return null;
}
function isPartialDate(date: string) {
return date.length === 14 && date[11] === 'T';
}
@@ -158,12 +200,16 @@ export const automaticMetadataProcessor = createMetadataProcessor({
detailsLine = detailsLine.replace(civitaiResources, '');
}
// Extract Civitai Metadata
const civitaiMetadataMatch = detailsLine?.match(civitaiMetadata)?.[1];
// Extract Civitai Metadata (uses balanced brace extraction to handle nested JSON)
const civitaiMetadataMatch = detailsLine
? extractBalancedJson(detailsLine, civitaiMetadataPrefix)
: null;
if (civitaiMetadataMatch && detailsLine) {
const data = JSON.parse(civitaiMetadataMatch) as Record<string, any>;
const data = JSON.parse(civitaiMetadataMatch.json) as Record<string, any>;
if (Object.keys(data).length !== 0) metadata.extra = data;
detailsLine = detailsLine.replace(civitaiMetadata, '');
detailsLine =
detailsLine.slice(0, civitaiMetadataMatch.start) +
detailsLine.slice(civitaiMetadataMatch.end);
}
// Extract fine details
+2 -1
View File
@@ -44,6 +44,7 @@
"./node_modules",
"./node_modules/*",
"src/pages/api/dev-local/*.ts",
"scripts/local-dev/*.ts"
"scripts/local-dev/*.ts",
"src/**/__tests__/**"
]
}