feat: validate canonical image asset digests

This commit is contained in:
mARTin-B78 2026-09-04 09:53:50 +02:00
parent 9b15dae205
commit 6ca044ef64
10 changed files with 51 additions and 6 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **Canonical Pair Asset Digests.** Generated profile/reference pairs now retain SHA-256 asset digests in character
metadata, and visual-pair review reports either asset being replaced after generation.
- **Live Image Result Preservation.** Durable image artifact persistence now leaves the active in-memory pair
available to the current browser poll while storing only bounded download metadata for restart-safe history.
- **Resource Diagnostics.** Performance now exposes read-only server RSS, configured/temp disk usage, persisted-job

View File

@ -48,11 +48,11 @@ the first block before investing in architecture or polish.
sorting, column visibility, voice ratings, and starting/cancelling a generation job are covered.
- [x] **M — Fix the Schwester Larissa unclosed-guillemet split.** Attribution normalization now repairs dialogue
split before mixed German guillemet closes (`»…‹` / `«…›`), with an epigraph regression test.
- [ ] **M — Make profile portraits and character sheets use one canonical identity.**
- [x] **M — Make profile portraits and character sheets use one canonical identity.**
Shared structured prompts now version consistent pairs as v3 and fingerprint canonical appearance fields; add
cross-record validation so mismatched profile/reference files are detected even when both are current-version. The
UI now flags missing, stale, changed, or explicitly inconsistent pair metadata; visual similarity itself still needs
an image-level review workflow.
UI now flags missing, stale, changed, or explicitly inconsistent pair metadata; visual similarity itself remains a
human review concern, while generated/replaced assets are now detected using stored SHA-256 digests.
- [x] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference, voice-design, and casting
jobs now deduplicate repeated requests with bounded, expiring `Idempotency-Key` contracts.
- [x] **M — Improve large-table performance.** Character productions and the flat voice library paginate at 50

View File

@ -1 +1 @@
1.24.7
1.24.8

View File

@ -112,6 +112,7 @@ def _validate_character_record(record: dict) -> dict:
"image_prompt": 8000, "concept_art_prompt": 8000,
"image": 24 * 1024 * 1024, "concept_art_image": 24 * 1024 * 1024,
"consistent_prompt_version": 120, "consistent_identity_fingerprint": 120,
"profile_asset_digest": 128, "reference_asset_digest": 128,
}.items():
value = form.get(field, "")
if value is not None and not isinstance(value, str):

View File

@ -132,6 +132,12 @@ async def characters_visual_pair_review():
if profile and reference:
if profile_digest and profile_digest == reference_digest:
issues.append("same_asset_for_profile_and_reference")
stored_profile_digest = str(sheet.get("profile_asset_digest") or "")
stored_reference_digest = str(sheet.get("reference_asset_digest") or "")
if stored_profile_digest and profile_digest and stored_profile_digest != profile_digest:
issues.append("profile_asset_changed_since_generation")
if stored_reference_digest and reference_digest and stored_reference_digest != reference_digest:
issues.append("reference_asset_changed_since_generation")
expected = _character_identity_fingerprint(sheet)
if sheet.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
issues.append("legacy_or_missing_prompt_version")

View File

@ -217,6 +217,16 @@ def _persist_consistent_image_artifacts(job_id: str, result: dict) -> dict:
return persisted
def _data_image_digest(value: object) -> str:
match = re.match(r"^data:image/[\w.+-]+;base64,(.+)$", str(value or ""), re.DOTALL)
if not match:
return ""
try:
return hashlib.sha256(base64.b64decode(match.group(1), validate=True)).hexdigest()
except Exception:
return ""
class _ConsistentImageCancelled(Exception):
"""Internal cooperative cancellation signal for a consistent-image worker."""
@ -3428,6 +3438,8 @@ async def character_generate_consistent_images(request: Request):
result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION
result["identity_consistent"] = True
result["identity_fingerprint"] = _character_identity_fingerprint(sheet)
result["profile_asset_digest"] = _data_image_digest(result.get("profile"))
result["reference_asset_digest"] = _data_image_digest(result.get("reference"))
result = _persist_consistent_image_artifacts(job_id, result)
_cij_set(job_id, stage="done", detail="Done", done=True, result=result)
except HTTPException as exc:

File diff suppressed because one or more lines are too long

View File

@ -3551,7 +3551,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
panel.setJobId(job_id);
const d = await _cijPollJob(job_id, panel);
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', identity_consistent: d.identity_consistent !== false };
const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', profile_asset_digest: d.profile_asset_digest || '', reference_asset_digest: d.reference_asset_digest || '', identity_consistent: d.identity_consistent !== false };
if (form) {
await _libSaveCharacterVisual(rec, visualFields);
const updatedForm = _libActiveCharacterForm(rec);

View File

@ -25,6 +25,7 @@ from routes.conversation import (
_request_timeout_seconds,
_response_error_text,
_persist_consistent_image_artifacts,
_data_image_digest,
)
@ -41,6 +42,11 @@ class AttributionParserTests(unittest.TestCase):
finally:
shutil.rmtree(_BACKGROUND_ARTIFACT_DIR / job_id, ignore_errors=True)
def test_generated_image_digest_is_stable_and_rejects_invalid_data_uri(self):
self.assertEqual(_data_image_digest("data:image/png;base64,AA=="), _data_image_digest("data:image/png;base64,AA=="))
self.assertTrue(_data_image_digest("data:image/png;base64,AA=="))
self.assertEqual(_data_image_digest("not-an-image"), "")
def test_active_character_form_overlays_visual_identity_without_changing_record(self):
sheet = {
"physical": "base form",

View File

@ -190,6 +190,24 @@ class SecurityValidationTests(unittest.TestCase):
self.assertIn("same_asset_for_profile_and_reference", result["issues"][0]["issues"])
self.assertIn("asset_reused_across_characters", result["issues"][0]["issues"])
def test_visual_pair_review_detects_replaced_current_assets(self):
import base64
from unittest.mock import patch
import routes.characters as characters
from routes.characters import characters_visual_pair_review
profile = "data:image/png;base64," + base64.b64encode(b"new-profile").decode()
reference = "data:image/png;base64," + base64.b64encode(b"new-reference").decode()
records = [{"id": "changed", "name": "Changed", "image": profile, "sheet": {
"concept_art_image": reference, "consistent_prompt_version": "v3",
"consistent_identity_fingerprint": "", "profile_asset_digest": "old-profile",
"reference_asset_digest": "old-reference",
}}]
with patch.object(characters, "char_get_all", return_value=records):
result = asyncio.run(characters_visual_pair_review())
issues = result["issues"][0]["issues"]
self.assertIn("profile_asset_changed_since_generation", issues)
self.assertIn("reference_asset_changed_since_generation", issues)
def test_cast_background_job_honours_queued_cancellation(self):
import routes.conversation as conversation
from unittest.mock import patch