Skip to content

Commit 710fb5c

Browse files
committed
feat: add caption quality diagnostics in progress UI
1 parent 6ca4f9d commit 710fb5c

8 files changed

Lines changed: 352 additions & 3 deletions

File tree

background/service-worker.js

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,17 @@ function getStyleSpec(options) {
4646

4747
var DEFAULT_PROVIDER_PROMPT_TEMPLATES = {
4848
openai: {
49-
storyboard: 'Create a comic storyboard as strict JSON.\nJSON only, no markdown.\nSchema: {"panels":[{"caption":string,"image_prompt":string}]}\nPanels: {{panel_count}}\nStyle: {{style_prompt}}\nContent:\n{{content}}',
49+
storyboard: 'Create a comic storyboard as strict JSON.\nJSON only, no markdown.\nSchema: {"panels":[{"caption":string,"image_prompt":string}]}\ncaption must be a short story beat for a reader (graphic-novel narration), not a visual prompt. image_prompt must be visual-generation instructions only.\nPanels: {{panel_count}}\nStyle: {{style_prompt}}\nContent:\n{{content}}',
5050
image: 'Comic panel {{panel_index}}/{{panel_count}}.\nCaption: {{panel_caption}}\nSummary: {{panel_summary}}\nStyle: {{style_prompt}}\nReturn a single image matching the comic style.'
5151
},
5252
gemini: {
53-
storyboard: 'Generate a comic storyboard in strict JSON.\nJSON only, no markdown.\nSchema: {"panels":[{"caption":string,"image_prompt":string}]}\nPanel count: {{panel_count}}\nStyle guidance: {{style_prompt}}\nContent:\n{{content}}',
53+
storyboard: 'Generate a comic storyboard in strict JSON.\nJSON only, no markdown.\nSchema: {"panels":[{"caption":string,"image_prompt":string}]}\ncaption must be a short story beat for a reader (graphic-novel narration), not a visual prompt. image_prompt must be visual-generation instructions only.\nPanel count: {{panel_count}}\nStyle guidance: {{style_prompt}}\nContent:\n{{content}}',
5454
image: 'Create comic panel artwork {{panel_index}}/{{panel_count}}.\nPanel caption: {{panel_caption}}\nPanel summary: {{panel_summary}}\nStyle guidance: {{style_prompt}}'
5555
}
5656
};
5757
var STORYBOARD_RETRY_JSON_ONLY_PROMPT = 'Return ONLY valid JSON object with top-level "panels" array. No markdown fences.';
58+
var STORYBOARD_CAPTION_IMAGE_PROMPT_RULE =
59+
'caption must be a short story beat for a reader (graphic-novel narration), not a visual prompt. image_prompt must be visual-generation instructions only.';
5860

5961
var DEFAULT_FETCH_TIMEOUT_MS = 45000;
6062
var STORYBOARD_TIMEOUT_MS = 90000;
@@ -388,12 +390,80 @@ function summarizeRawOutputForRetry(error) {
388390
return 'Previous malformed output snippet: "' + snippet + '"';
389391
}
390392

393+
function looksLikeImagePromptText(value) {
394+
var s = normalizeLooseTextValue(value).trim();
395+
if (!s) return false;
396+
var lower = s.toLowerCase();
397+
if (s.length > 220) return true;
398+
var promptPhrases = [
399+
'comic panel illustration',
400+
'illustration of',
401+
'digital art',
402+
'cinematic lighting',
403+
'highly detailed',
404+
'camera angle',
405+
'art style',
406+
'dramatic lighting',
407+
'ultra detailed'
408+
];
409+
for (var i = 0; i < promptPhrases.length; i++) {
410+
if (lower.indexOf(promptPhrases[i]) >= 0) return true;
411+
}
412+
var commaCount = (s.match(/,/g) || []).length;
413+
if (commaCount >= 6) return true;
414+
return false;
415+
}
416+
417+
function rewritePromptLikeCaptionToStoryBeat(captionText, panel, index) {
418+
var storyCandidates = [
419+
panel && panel.beat_summary,
420+
panel && panel.summary,
421+
panel && panel.beat,
422+
panel && panel.narration,
423+
panel && panel.description,
424+
panel && panel.title,
425+
panel && panel.text,
426+
panel && panel.text_content,
427+
panel && panel.caption_text,
428+
panel && panel.dialogue
429+
];
430+
for (var i = 0; i < storyCandidates.length; i++) {
431+
var candidate = normalizeLooseTextValue(storyCandidates[i]);
432+
if (candidate && !looksLikeImagePromptText(candidate)) return candidate;
433+
}
434+
435+
// Heuristic fallback: strip common prompt-style boilerplate and visual jargon.
436+
var src = normalizeLooseTextValue(captionText);
437+
if (!src) return 'Panel ' + (index + 1);
438+
var out = src
439+
.replace(/^comic panel illustration of:\s*/i, '')
440+
.replace(/^illustration of:\s*/i, '')
441+
.replace(/\b(digital art|cinematic lighting|highly detailed|ultra detailed|dramatic lighting|camera angle[^,.;]*|art style[^,.;]*)\b/gi, '')
442+
.replace(/\s+/g, ' ')
443+
.replace(/\s*,\s*,+/g, ', ')
444+
.replace(/^[,.\s-]+|[,.\s-]+$/g, '');
445+
// Prefer first natural clause/sentence fragment as story beat.
446+
var split = out.split(/[.;]|, and /i).map(function(part) { return part.trim(); }).filter(Boolean);
447+
var candidateBeat = split[0] || out;
448+
if (!candidateBeat) return 'Panel ' + (index + 1);
449+
if (!/[.!?]$/.test(candidateBeat)) candidateBeat += '.';
450+
return candidateBeat.substring(0, 180);
451+
}
452+
391453
function validateStoryboardContract(storyboard, requestedPanelCount) {
392454
var normalized = (storyboard && typeof storyboard === 'object') ? storyboard : {};
393455
if (!Array.isArray(normalized.panels)) normalized.panels = [];
394456

395457
var beforeMissingCaption = 0;
396458
var beforeMissingImagePrompt = 0;
459+
var promptLikeCaptionRepairs = 0;
460+
var captionQuality = {
461+
totalPanels: 0,
462+
nonEmptyCaptions: 0,
463+
storyLikeCaptions: 0,
464+
promptLikeCaptions: 0,
465+
fallbackPanelLabelCaptions: 0
466+
};
397467

398468
normalized.panels = normalized.panels
399469
.map(function(panel, index) {
@@ -406,10 +476,27 @@ function validateStoryboardContract(storyboard, requestedPanelCount) {
406476
if (!caption) beforeMissingCaption += 1;
407477
if (!imagePrompt) beforeMissingImagePrompt += 1;
408478

479+
if (caption && (looksLikeImagePromptText(caption) || (imagePrompt && caption === imagePrompt))) {
480+
var repairedCaption = rewritePromptLikeCaptionToStoryBeat(caption, p, index);
481+
if (repairedCaption && repairedCaption !== caption) {
482+
caption = repairedCaption;
483+
promptLikeCaptionRepairs += 1;
484+
}
485+
}
486+
409487
p.beat_summary = p.beat_summary || beat || '';
410488
p.caption = caption || beat || ('Panel ' + (index + 1));
411489
p.image_prompt = imagePrompt || ('Comic panel illustration of: ' + p.caption + (beat ? ('. ' + beat) : ''));
412490
if (!p.panel_id) p.panel_id = 'panel_' + (index + 1);
491+
492+
captionQuality.totalPanels += 1;
493+
var finalCaption = normalizeLooseTextValue(p.caption);
494+
if (finalCaption) {
495+
captionQuality.nonEmptyCaptions += 1;
496+
if (looksLikeImagePromptText(finalCaption)) captionQuality.promptLikeCaptions += 1;
497+
else captionQuality.storyLikeCaptions += 1;
498+
if (/^Panel\s+\d+\.?$/i.test(finalCaption)) captionQuality.fallbackPanelLabelCaptions += 1;
499+
}
413500
return p;
414501
})
415502
.filter(function(panel) { return !!panel; });
@@ -424,7 +511,9 @@ function validateStoryboardContract(storyboard, requestedPanelCount) {
424511
hasPanelsArray: Array.isArray((storyboard && storyboard.panels)) || Array.isArray(normalized.panels),
425512
panelCount: normalized.panels.length,
426513
missingCaptionBeforeSynthesis: beforeMissingCaption,
427-
missingImagePromptBeforeSynthesis: beforeMissingImagePrompt
514+
missingImagePromptBeforeSynthesis: beforeMissingImagePrompt,
515+
promptLikeCaptionRepairs: promptLikeCaptionRepairs,
516+
captionQuality: captionQuality
428517
}
429518
};
430519
}
@@ -558,6 +647,7 @@ class GeminiProvider {
558647
var prompt = 'Create a ' + panelCount + '-panel comic storyboard.\n' +
559648
'JSON only, no markdown.\n' +
560649
'Schema: {"panels":[{"caption":string,"image_prompt":string}]}\n' +
650+
STORYBOARD_CAPTION_IMAGE_PROMPT_RULE + '\n' +
561651
'Visual style requirement: ' + styleSpec.directive + '\n' +
562652
'Keep the style consistent across all panels.\n' +
563653
'Text: ' + text.substring(0, 4000);
@@ -687,6 +777,7 @@ class OpenAIProvider {
687777
'Create a ' + (options.panelCount || 6) + '-panel comic storyboard. ' +
688778
'JSON only, no markdown. ' +
689779
'Schema: {"panels":[{"caption":string,"image_prompt":string}]}. ' +
780+
STORYBOARD_CAPTION_IMAGE_PROMPT_RULE + ' ' +
690781
'Style requirement: ' + styleSpec.directive + '. ' +
691782
'Content: ' + text.substring(0, 8000);
692783
if (options && options.storyboardTemplate) {
@@ -723,6 +814,7 @@ class OpenAIProvider {
723814
content:
724815
'You are a comic storyboard generator. Respond with JSON only, no markdown fences. ' +
725816
'Schema: {"panels":[{"caption":string,"image_prompt":string}]}. ' +
817+
STORYBOARD_CAPTION_IMAGE_PROMPT_RULE + ' ' +
726818
'Include the requested art style in each panel image_prompt.'
727819
},
728820
{
@@ -909,6 +1001,7 @@ class OpenRouterProvider {
9091001
'Create a comic storyboard from the content below.',
9101002
'JSON only, no markdown.',
9111003
'Schema: {"panels":[{"caption":string,"image_prompt":string}]}',
1004+
STORYBOARD_CAPTION_IMAGE_PROMPT_RULE,
9121005
'Panel count: ' + panelCount,
9131006
'Style requirement: ' + styleSpec.directive,
9141007
'Keep the style consistent across all panels.',
@@ -1193,6 +1286,7 @@ class HuggingFaceProvider {
11931286
var prompt = [
11941287
'JSON only, no markdown.',
11951288
'Schema: {"panels":[{"caption":string,"image_prompt":string}]}',
1289+
STORYBOARD_CAPTION_IMAGE_PROMPT_RULE,
11961290
'Create a ' + panelCount + '-panel comic storyboard.',
11971291
'Style requirement: ' + styleSpec.directive,
11981292
'Content:',
@@ -1513,6 +1607,7 @@ class CloudflareProvider {
15131607
'Create a comic storyboard from the content below.',
15141608
'JSON only, no markdown.',
15151609
'Schema: {"panels":[{"caption":string,"image_prompt":string}]}',
1610+
STORYBOARD_CAPTION_IMAGE_PROMPT_RULE,
15161611
'Panel count: ' + panelCount,
15171612
'Style requirement: ' + styleSpec.directive,
15181613
'Keep image prompts concise but descriptive and safe for image generation.',
@@ -1546,6 +1641,7 @@ class CloudflareProvider {
15461641
var data = await this.callModel(model, {
15471642
messages: [
15481643
{ role: 'system', content: 'You generate comic storyboards as strict JSON only.' },
1644+
{ role: 'system', content: STORYBOARD_CAPTION_IMAGE_PROMPT_RULE },
15491645
{ role: 'user', content: prompt }
15501646
],
15511647
max_tokens: 2048,
@@ -2391,6 +2487,22 @@ var ServiceWorker = function() {
23912487
noPanelsErr.providerId = (storyboard && storyboard.settings && storyboard.settings.provider_text) || settings.provider_text;
23922488
throw noPanelsErr;
23932489
}
2490+
if (contract.meta.promptLikeCaptionRepairs > 0) {
2491+
self.appendDebugLog('unexpected_output.storyboard.prompt_like_captions_repaired', {
2492+
count: contract.meta.promptLikeCaptionRepairs,
2493+
sourceUrl: job.sourceUrl || null,
2494+
provider: (storyboard && storyboard.settings && storyboard.settings.provider_text) || settings.provider_text
2495+
});
2496+
}
2497+
job.captionQuality = {
2498+
...(contract.meta.captionQuality || {}),
2499+
promptLikeCaptionRepairs: contract.meta.promptLikeCaptionRepairs || 0
2500+
};
2501+
self.appendDebugLog('caption_quality.score', {
2502+
provider: (storyboard && storyboard.settings && storyboard.settings.provider_text) || settings.provider_text,
2503+
sourceUrl: job.sourceUrl || null,
2504+
score: job.captionQuality || null
2505+
});
23942506
storyboard.source = { url: job.sourceUrl, title: job.sourceTitle, extracted_at: new Date().toISOString() };
23952507
storyboard.panels = (Array.isArray(storyboard.panels) ? storyboard.panels : []).map(function(panel, idx) {
23962508
var p = panel || {};
@@ -2405,6 +2517,7 @@ var ServiceWorker = function() {
24052517
show_rewritten_badge: settings.show_rewritten_badge !== false,
24062518
log_rewritten_prompts: !!settings.log_rewritten_prompts
24072519
};
2520+
storyboard.caption_quality = job.captionQuality || null;
24082521
job.storyboard = storyboard;
24092522
job.status = 'generating_images';
24102523
job.currentPanelIndex = 0;

popup/popup.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ <h3>Step 2: Configure Comic</h3>
225225
<div class="panel-title-row"><h3>Generating Comic</h3><a class="help-link-icon inline" href="../docs/user-manual.html#popup-progress" target="_blank" rel="noopener noreferrer" title="Progress view help">?</a></div>
226226
<div id="progress-status" class="progress-status">Preparing...</div>
227227
<div id="progress-status-detail" class="progress-status-detail">Elapsed 0s | Waiting for updates...</div>
228+
<div id="progress-caption-quality" class="progress-status-detail hidden"></div>
228229
<div id="progress-debug-log" class="progress-debug-log hidden"></div>
229230

230231
<div class="progress-bar-container">

popup/popup.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,10 @@ class PopupController {
10931093
if (document.getElementById('progress-status-detail')) {
10941094
document.getElementById('progress-status-detail').textContent = 'Elapsed 0s | Waiting for updates...';
10951095
}
1096+
if (document.getElementById('progress-caption-quality')) {
1097+
document.getElementById('progress-caption-quality').textContent = '';
1098+
document.getElementById('progress-caption-quality').classList.add('hidden');
1099+
}
10961100
this.progressStartedAtMs = Date.now();
10971101
this.progressFirstPanelAtMs = 0;
10981102
this.cancelRequestedByUser = false;
@@ -1195,6 +1199,16 @@ class PopupController {
11951199
return `Elapsed ${this.formatDurationShort(elapsedMs)} | ${phaseText} | ${etaText}`;
11961200
}
11971201

1202+
buildCaptionQualitySummary(job) {
1203+
const score = job && (job.captionQuality || job?.storyboard?.caption_quality);
1204+
if (!score || typeof score !== 'object') return '';
1205+
const storyLike = Math.max(0, Number(score.storyLikeCaptions || 0));
1206+
const promptLike = Math.max(0, Number(score.promptLikeCaptions || 0));
1207+
const repaired = Math.max(0, Number(score.promptLikeCaptionRepairs || 0));
1208+
if (storyLike === 0 && promptLike === 0 && repaired === 0) return '';
1209+
return `Caption quality: ${storyLike} story-like / ${promptLike} prompt-like${repaired > 0 ? ` (repaired ${repaired})` : ''}`;
1210+
}
1211+
11981212
startProgressPolling() {
11991213
var lastStatus = null;
12001214
var consecutiveReadErrors = 0;
@@ -1299,6 +1313,7 @@ class PopupController {
12991313
updateProgressUI(job) {
13001314
const statusEl = document.getElementById('progress-status');
13011315
const statusDetailEl = document.getElementById('progress-status-detail');
1316+
const captionQualityEl = document.getElementById('progress-caption-quality');
13021317
const progressBar = document.getElementById('progress-bar');
13031318
const panelProgress = document.getElementById('panel-progress');
13041319
const debugLogEl = document.getElementById('progress-debug-log');
@@ -1340,6 +1355,11 @@ class PopupController {
13401355
if (statusDetailEl) {
13411356
statusDetailEl.textContent = this.buildProgressTimingDetail(job, panelCount);
13421357
}
1358+
if (captionQualityEl) {
1359+
const captionSummary = this.settings.debugFlag ? this.buildCaptionQualitySummary(job) : '';
1360+
captionQualityEl.textContent = captionSummary;
1361+
captionQualityEl.classList.toggle('hidden', !captionSummary);
1362+
}
13431363
if (panels) {
13441364
const totalPanels = panels.length;
13451365
const completed = job.completedPanels ?? 0;

sidepanel/sidepanel.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ <h3 id="gen-status-title">Generating Comic...</h3>
114114
</div>
115115
<p id="gen-status-text">Preparing your comic strip</p>
116116
<p id="gen-status-detail" class="gen-status-detail">Elapsed 0s | Waiting for updates... | ETA: calculating...</p>
117+
<p id="gen-caption-quality" class="gen-status-detail hidden"></p>
117118
</div>
118119

119120
<div id="gen-panels" class="gen-panels">

sidepanel/sidepanel.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,16 @@ class ComicViewer {
806806
return `Elapsed ${this.formatDurationShort(elapsedMs)} | ${phaseText} | ${etaText}`;
807807
}
808808

809+
buildCaptionQualitySummary(job) {
810+
const score = job && (job.captionQuality || job?.storyboard?.caption_quality);
811+
if (!score || typeof score !== 'object') return '';
812+
const storyLike = Math.max(0, Number(score.storyLikeCaptions || 0));
813+
const promptLike = Math.max(0, Number(score.promptLikeCaptions || 0));
814+
const repaired = Math.max(0, Number(score.promptLikeCaptionRepairs || 0));
815+
if (storyLike === 0 && promptLike === 0 && repaired === 0) return '';
816+
return `Caption quality: ${storyLike} story-like / ${promptLike} prompt-like${repaired > 0 ? ` (repaired ${repaired})` : ''}`;
817+
}
818+
809819
updateGenerationUI(job) {
810820
const statusTitles = {
811821
pending: 'Preparing...',
@@ -834,6 +844,7 @@ class ComicViewer {
834844
cancelBtn.disabled = !['pending', 'generating_text', 'generating_images'].includes(String(job.status || ''));
835845
}
836846
const detailEl = document.getElementById('gen-status-detail');
847+
const captionQualityEl = document.getElementById('gen-caption-quality');
837848

838849
const panelsContainer = document.getElementById('gen-panels');
839850
if (!panelsContainer) return;
@@ -847,6 +858,12 @@ class ComicViewer {
847858
if (detailEl) {
848859
detailEl.textContent = this.buildGenerationStatusDetail(job, totalPanels);
849860
}
861+
if (captionQualityEl) {
862+
const debugEnabled = !!(job?.storyboard?.settings?.debug_flag || job?.settings?.debug_flag);
863+
const captionSummary = debugEnabled ? this.buildCaptionQualitySummary(job) : '';
864+
captionQualityEl.textContent = captionSummary;
865+
captionQualityEl.classList.toggle('hidden', !captionSummary);
866+
}
850867

851868
const normalizedPanels = Array.from({ length: totalPanels || 0 }, (_, index) => {
852869
const panel = storyboardPanels[index] || null;

tests/integration/popup-page.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,64 @@ describe('Popup Page Startup', () => {
835835
expect(text).toMatch(/Rendering panels|Completed/);
836836
});
837837

838+
it('shows debug-only caption quality summary in popup progress view', async () => {
839+
chrome.storage.local.get.mockImplementation(async (keys) => {
840+
if (Array.isArray(keys)) {
841+
const result = {
842+
settings: { activeTextProvider: 'openai', activeImageProvider: 'openai', debugFlag: true },
843+
providers: {}
844+
};
845+
if (keys.includes('apiKeys')) result.apiKeys = { openai: global.TEST_OPENAI_API_KEY };
846+
if (keys.includes('providerValidation')) result.providerValidation = { openai: { valid: true } };
847+
return result;
848+
}
849+
if (keys === 'history') return { history: [] };
850+
if (keys === 'apiKeys') return { apiKeys: { openai: global.TEST_OPENAI_API_KEY } };
851+
if (keys === 'providerValidation') return { providerValidation: { openai: { valid: true } } };
852+
if (keys === 'debugLogs') return { debugLogs: [] };
853+
if (keys === 'currentJob') {
854+
return {
855+
currentJob: {
856+
id: 'job-caption-quality',
857+
status: 'generating_images',
858+
completedPanels: 1,
859+
currentPanelIndex: 1,
860+
captionQuality: {
861+
storyLikeCaptions: 5,
862+
promptLikeCaptions: 1,
863+
promptLikeCaptionRepairs: 1
864+
},
865+
settings: { panel_count: 3, debug_flag: true },
866+
storyboard: { settings: { debug_flag: true }, panels: [{}, {}, {}] }
867+
}
868+
};
869+
}
870+
return {};
871+
});
872+
873+
chrome.tabs.sendMessage.mockImplementation(async (_tabId, msg) => {
874+
if (msg.type === 'EXTRACT_CONTENT') return { success: true, text: 'x'.repeat(400) };
875+
if (msg.type === 'START_GENERATION') return { success: true, jobId: 'job-caption-quality' };
876+
return { success: false };
877+
});
878+
879+
await import('../../popup/popup.js');
880+
document.dispatchEvent(new Event('DOMContentLoaded'));
881+
await flush();
882+
await flush();
883+
884+
document.getElementById('create-comic-btn').click();
885+
await flush();
886+
await flush();
887+
document.getElementById('generate-btn').click();
888+
await new Promise((resolve) => setTimeout(resolve, 550));
889+
890+
const line = document.getElementById('progress-caption-quality');
891+
expect(line).toBeTruthy();
892+
expect(line.classList.contains('hidden')).toBe(false);
893+
expect(String(line.textContent || '')).toContain('Caption quality: 5 story-like / 1 prompt-like (repaired 1)');
894+
});
895+
838896
it('shows rate-limit retry countdown in popup progress detail when retryState is present', async () => {
839897
chrome.storage.local.get.mockImplementation(async (keys) => {
840898
if (Array.isArray(keys)) {

0 commit comments

Comments
 (0)