StepPageSplit now: - Auto-calls POST /page-split on step entry - Shows oriented image + detection result - If double page: creates sub-sessions named "Title — S. 1/2" - If single page: green badge "keine Trennung noetig" - Manual "Weiter" button (no auto-advance) Also: - StepOrientation wrapper simplified (no page-split in orientation) - StepUpload passes name back via onUploaded(sid, name) - page.tsx: after page-split "Weiter" switches to first sub-session - useKombiPipeline exposes setSessionName Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
7.4 KiB
TypeScript
202 lines
7.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import type { SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface PageSplitResult {
|
|
multi_page: boolean
|
|
page_count?: number
|
|
page_splits?: { x: number; y: number; width: number; height: number; page_index: number }[]
|
|
sub_sessions?: { id: string; name: string; page_index: number }[]
|
|
used_original?: boolean
|
|
duration_seconds?: number
|
|
}
|
|
|
|
interface StepPageSplitProps {
|
|
sessionId: string | null
|
|
sessionName: string
|
|
onNext: () => void
|
|
onSubSessionsCreated: (subs: SubSession[]) => void
|
|
}
|
|
|
|
export function StepPageSplit({ sessionId, sessionName, onNext, onSubSessionsCreated }: StepPageSplitProps) {
|
|
const [detecting, setDetecting] = useState(false)
|
|
const [splitResult, setSplitResult] = useState<PageSplitResult | null>(null)
|
|
const [error, setError] = useState('')
|
|
const didDetect = useRef(false)
|
|
|
|
// Auto-detect page split when step opens
|
|
useEffect(() => {
|
|
if (!sessionId || didDetect.current) return
|
|
didDetect.current = true
|
|
detectPageSplit()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId])
|
|
|
|
const detectPageSplit = async () => {
|
|
if (!sessionId) return
|
|
setDetecting(true)
|
|
setError('')
|
|
try {
|
|
// First check if sub-sessions already exist
|
|
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
|
if (sessionRes.ok) {
|
|
const sessionData = await sessionRes.json()
|
|
if (sessionData.sub_sessions?.length > 0) {
|
|
// Already split — show existing sub-sessions
|
|
const subs = sessionData.sub_sessions as { id: string; name: string; page_index?: number; box_index?: number; current_step?: number }[]
|
|
setSplitResult({
|
|
multi_page: true,
|
|
page_count: subs.length,
|
|
sub_sessions: subs.map((s: { id: string; name: string; page_index?: number; box_index?: number }) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
page_index: s.page_index ?? s.box_index ?? 0,
|
|
})),
|
|
})
|
|
onSubSessionsCreated(subs.map((s: { id: string; name: string; page_index?: number; box_index?: number; current_step?: number }) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
box_index: s.page_index ?? s.box_index ?? 0,
|
|
current_step: s.current_step ?? 2,
|
|
})))
|
|
setDetecting(false)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Run page-split detection
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/page-split`, {
|
|
method: 'POST',
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || 'Seitentrennung fehlgeschlagen')
|
|
}
|
|
const data: PageSplitResult = await res.json()
|
|
setSplitResult(data)
|
|
|
|
if (data.multi_page && data.sub_sessions?.length) {
|
|
// Rename sub-sessions to "Title — S. 1", "Title — S. 2"
|
|
const baseName = sessionName || 'Dokument'
|
|
for (let i = 0; i < data.sub_sessions.length; i++) {
|
|
const sub = data.sub_sessions[i]
|
|
const newName = `${baseName} — S. ${i + 1}`
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sub.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newName }),
|
|
}).catch(() => {})
|
|
sub.name = newName
|
|
}
|
|
|
|
onSubSessionsCreated(data.sub_sessions.map(s => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
box_index: s.page_index,
|
|
current_step: 2,
|
|
})))
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setDetecting(false)
|
|
}
|
|
}
|
|
|
|
if (!sessionId) return null
|
|
|
|
const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/oriented`
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Image */}
|
|
<div className="relative rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={imageUrl}
|
|
alt="Orientiertes Bild"
|
|
className="w-full object-contain max-h-[500px]"
|
|
onError={(e) => {
|
|
// Fallback to non-oriented image
|
|
(e.target as HTMLImageElement).src =
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image`
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Detection status */}
|
|
{detecting && (
|
|
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400 text-sm">
|
|
<div className="animate-spin w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full" />
|
|
Doppelseiten-Erkennung laeuft...
|
|
</div>
|
|
)}
|
|
|
|
{/* Detection result */}
|
|
{splitResult && !detecting && (
|
|
splitResult.multi_page ? (
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700 p-4 space-y-2">
|
|
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
|
Doppelseite erkannt — {splitResult.page_count} Seiten getrennt
|
|
</div>
|
|
<p className="text-xs text-blue-600 dark:text-blue-400">
|
|
Jede Seite wird als eigene Session weiterverarbeitet (eigene Begradigung, Entzerrung, etc.).
|
|
{splitResult.used_original && ' Trennung auf Originalbild, da Orientierung die Doppelseite gedreht hat.'}
|
|
</p>
|
|
<div className="flex gap-2 mt-2">
|
|
{splitResult.sub_sessions?.map(s => (
|
|
<span
|
|
key={s.id}
|
|
className="text-xs px-2.5 py-1 rounded-md bg-blue-100 dark:bg-blue-800/40 text-blue-700 dark:text-blue-300 font-medium"
|
|
>
|
|
{s.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{splitResult.duration_seconds != null && (
|
|
<div className="text-xs text-gray-400">{splitResult.duration_seconds.toFixed(1)}s</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800 p-4">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-green-700 dark:text-green-300">
|
|
<span>✓</span> Einzelseite — keine Trennung noetig
|
|
</div>
|
|
{splitResult.duration_seconds != null && (
|
|
<div className="text-xs text-gray-400 mt-1">{splitResult.duration_seconds.toFixed(1)}s</div>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
|
{error}
|
|
<button
|
|
onClick={() => { didDetect.current = false; detectPageSplit() }}
|
|
className="ml-2 text-teal-600 hover:underline"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Next button — only show when detection is done */}
|
|
{(splitResult || error) && !detecting && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={onNext}
|
|
className="px-6 py-2.5 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 transition-colors"
|
|
>
|
|
Weiter →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|