feat: validate canonical image asset digests
This commit is contained in:
parent
9b15dae205
commit
6ca044ef64
@ -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
|
||||
|
||||
6
TODO.md
6
TODO.md
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
2
static/dist/main.min.js
vendored
2
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user