feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests)

4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.

u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema,
  miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve.
u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes.
u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal).
u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge.
u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides
  with 300ms debounce and mutated-axis partial payloads.
u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame
  axes immediately, frames remapped post-loadRun unit_id → region.id).
u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize,
  frame select); zone_sizes and Generate excluded.
u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt,
  invalid keys (26 tests).
u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop,
  corrupt warning+skip (16 tests).
u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline
  in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests).

Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 /
service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates /
families / frames / pipeline orchestration outside the IMP-52 surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:47:11 +09:00
parent ee97f4fc78
commit 9388e25e76
12 changed files with 3674 additions and 44 deletions

View File

@@ -2,7 +2,7 @@
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
*/
import { useState, useCallback, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { toast } from "sonner";
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
import {
@@ -15,6 +15,9 @@ import {
getSelectedRegion,
moveSectionToZone,
saveZoneSizes,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
remapPersistedFramesToZoneFrames,
} from "../utils/slidePlanUtils";
import {
parseMdxFile,
@@ -25,6 +28,12 @@ import {
type RunMeta,
type PipelineOverrides,
} from "../services/designAgentApi";
import {
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverrides,
} from "../services/userOverridesApi";
import LeftMdxPanel from "../components/LeftMdxPanel";
import SlideCanvas from "../components/SlideCanvas";
@@ -63,6 +72,14 @@ export default function Home() {
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
// handleFileUpload time. layout / zone_geometries / zone_sections are
// seeded into userSelection immediately (so handleGenerate forwards them
// as CLI args). frames are stashed here because their on-disk key
// (unit_id = section_ids joined by "+") only maps to region.id after
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
@@ -180,7 +197,19 @@ export default function Home() {
try {
const content = await parseMdxFile(file);
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
// {} on miss / corrupt / network failure (u5 contract) so the upload
// path never fails on a fresh MDX.
const overridesKey = deriveUserOverridesKey(file.name);
const persisted = await getUserOverrides(overridesKey);
persistedOverridesRef.current = persisted;
setState((p) => ({
...p,
normalizedContent: content,
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
isLoading: false,
}));
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
} catch (err) {
console.error(err);
@@ -257,9 +286,11 @@ export default function Home() {
const overrides: PipelineOverrides = {};
const sourcePlan = effectiveSlidePlan;
if (sourcePlan && state.slidePlan) {
const defaultLayout = state.slidePlan.layout_preset;
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
const overrideLayout = state.userSelection.overrides.layout_preset;
if (overrideLayout && overrideLayout !== defaultLayout) {
if (overrideLayout) {
overrides.layout = overrideLayout;
}
const frames: Record<string, string> = {};
@@ -302,12 +333,9 @@ export default function Home() {
overrides.zoneGeometries = zoneGeometries;
}
// IMP-08 B-3 : zoneSections forward only when the user diverged from
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
// seeds `zone_sections` with the default placement, so a literal copy
// would pollute backend assignment-source provenance even on a fresh
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
// only emit zones whose section list differs.
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const defaultByZone = new Map<string, string[]>();
@@ -349,6 +377,12 @@ export default function Home() {
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
try {
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
// reads user_overrides.json on pipeline entry. Without this, a user
// who changes an override (300ms debounce window) and immediately
// clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document.
await flushUserOverrides();
const result = await runPipeline(state.uploadedFile, overrides);
if (!result.success || !result.final_html_exists) {
@@ -362,13 +396,42 @@ export default function Home() {
}
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
setState((p) => ({
...p,
normalizedContent,
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
// the user_overrides.json read at handleFileUpload time. Frames there
// are keyed by unit_id (section_ids joined by "+"); the in-memory
// zone_frames is keyed by region.id. Remap against the new slidePlan
// zones so SlideCanvas's override-vs-default preview indicator shows
// the user's persisted choice without forcing them to re-click.
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
slidePlan,
userSelection: createInitialUserSelection(slidePlan),
isLoading: false,
}));
persistedOverridesRef.current.frames as Record<string, string> | undefined,
);
setState((p) => {
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
// axes (layout / zone_geometries / zone_sections) onto the post-load
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
// drops anything the backend fallback could not round-trip through
// a CLI arg — `zone_geometries` in particular has no slidePlan
// representation, so without this merge the user would see their
// resized zones revert on every Generate.
const base = applyPersistedNonFrameOverrides(
createInitialUserSelection(slidePlan),
persistedOverridesRef.current,
);
return {
...p,
normalizedContent,
slidePlan,
userSelection: {
...base,
overrides: {
...base.overrides,
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
},
},
isLoading: false,
};
});
setRunMeta(runMeta);
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
@@ -386,10 +449,21 @@ export default function Home() {
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
setState((p) => {
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
return {
...p,
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
};
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
// section_ids), so we forward the full mutated value; the u4 PUT path
// replaces this axis atomically while preserving the foreign axes.
// p.uploadedFile gate skips persistence before any MDX is loaded —
// the demo-mode initial render path would otherwise PUT to the empty
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
zone_sections: finalSelection.overrides.zone_sections,
});
}
return { ...p, userSelection: finalSelection };
});
setRightTab("frame");
setHasPendingChanges(true);
@@ -415,10 +489,18 @@ export default function Home() {
// ── Layout 선택 ──
const handleLayoutSelect = useCallback((layoutId: string) => {
setState((p) => ({
...p,
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
}));
setState((p) => {
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
// IMP-52 u7 — persist the selected layout preset id. The on-disk
// `layout` axis is a single string; `applyLayout` validates the
// preset id before mutating the selection, so the value here is
// already the LayoutPresetId we want to round-trip.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { layout: layoutId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true);
}, []);
@@ -431,19 +513,30 @@ export default function Home() {
}, []);
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
setState((p) => ({
...p,
userSelection: {
...p.userSelection,
overrides: {
...p.userSelection.overrides,
zone_geometries: {
...p.userSelection.overrides.zone_geometries,
...geometries
}
}
setState((p) => {
const mergedGeometries = {
...p.userSelection.overrides.zone_geometries,
...geometries,
};
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
// gestures fire repeatedly during a drag; the 300ms u5 debounce
// collapses them into a single PUT at gesture-end, so we don't
// need to gate on resize-finished here.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
}
}));
return {
...p,
userSelection: {
...p.userSelection,
overrides: {
...p.userSelection.overrides,
zone_geometries: mergedGeometries,
},
},
};
});
setHasPendingChanges(true);
}, []);
@@ -486,10 +579,40 @@ export default function Home() {
return;
}
setState((p) => ({
...p,
userSelection: applyFrame(p.userSelection, region.id, frameId)
}));
setState((p) => {
const newSelection = applyFrame(p.userSelection, region.id, frameId);
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
// uses `unit_id = zone.section_ids.join("+")` (the same convention
// handleGenerate uses when forwarding `overrides.frames` to the
// backend CLI). `zone_frames` is keyed by region.id, so we walk
// the effectiveSlidePlan zones to translate. Only true user
// overrides are persisted — `createInitialUserSelection` pre-fills
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
// (backend default) for every region, so we mirror handleGenerate's
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
// into user_overrides.json. Zones with no sections are skipped.
if (p.uploadedFile && effectiveSlidePlan) {
const framesByUnitId: Record<string, string> = {};
for (const z of effectiveSlidePlan.zones) {
const r = z.internal_regions[0];
if (!r) continue;
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
const unitId = z.section_ids.join("+");
const overrideId = newSelection.overrides.zone_frames?.[r.id];
const defaultFrameId = r.frame_match_strategy.frame_id;
if (
typeof overrideId === "string" &&
overrideId.length > 0 &&
overrideId !== defaultFrameId
) {
framesByUnitId[unitId] = overrideId;
}
}
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { frames: framesByUnitId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true);
}, [effectiveSlidePlan, state.userSelection]);

View File

@@ -0,0 +1,221 @@
// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT).
//
// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by:
// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2)
// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4)
// This module is the typed view used by Home.tsx restore-on-reopen (u6) and
// the four mutation handlers (u7). It does NOT own the schema — any change
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
//
// Contract (Stage 2 unit u5 summary):
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
// PUT carrying ONLY the axes the user has mutated since the last flush.
// Per-axis coalescing: a later call overwrites the same axis in the
// pending payload; axes the user did not mutate are NOT sent (the
// server-side merge in u4 preserves them on disk).
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
// flush for MDX "04".
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
// writes commit before pipeline run).
const ENDPOINT_BASE = "/api/user-overrides";
const DEBOUNCE_MS = 300;
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
export type FramesOverride = Record<string, string>;
/** zone_id → 0-1 normalized geometry inside slide-body. */
export type ZoneGeometryOverride = {
x: number;
y: number;
w: number;
h: number;
};
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
/** zone_id → ordered list of section_ids assigned to that zone. */
export type ZoneSectionsOverride = Record<string, string[]>;
/** Full on-disk schema. All axes optional — file may carry any subset. */
export interface UserOverrides {
layout: string;
frames: FramesOverride;
zone_geometries: ZoneGeometriesOverride;
zone_sections: ZoneSectionsOverride;
}
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
export type UserOverridesPartial = {
[K in keyof UserOverrides]?: UserOverrides[K] | null;
};
// ── Per-key debounce buckets ────────────────────────────────────────────────
type PendingBucket = {
partial: UserOverridesPartial;
timer: ReturnType<typeof setTimeout> | null;
waiters: Array<{
resolve: (merged: Partial<UserOverrides>) => void;
reject: (err: unknown) => void;
}>;
};
const buckets = new Map<string, PendingBucket>();
function getBucket(key: string): PendingBucket {
let b = buckets.get(key);
if (!b) {
b = { partial: {}, timer: null, waiters: [] };
buckets.set(key, b);
}
return b;
}
// ── GET ─────────────────────────────────────────────────────────────────────
/**
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
* can use it unconditionally during MDX reopen without branching on
* error paths.
*/
export async function getUserOverrides(
key: string,
): Promise<Partial<UserOverrides>> {
let res: Response;
try {
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "GET",
headers: { Accept: "application/json" },
});
} catch {
return {};
}
if (!res.ok) return {};
let parsed: unknown;
try {
parsed = await res.json();
} catch {
return {};
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return {};
}
return parsed as Partial<UserOverrides>;
}
// ── PUT (debounced) ─────────────────────────────────────────────────────────
async function flushBucket(
key: string,
bucket: PendingBucket,
): Promise<void> {
const payload = bucket.partial;
const waiters = bucket.waiters;
bucket.partial = {};
bucket.timer = null;
bucket.waiters = [];
let merged: Partial<UserOverrides> = {};
try {
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
try {
const parsed = (await res.json()) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
merged = parsed as Partial<UserOverrides>;
}
} catch {
// server returned 200 with non-JSON body → treat as empty merged
}
} else {
const err = new Error(`PUT ${ENDPOINT_BASE}/${key}${res.status}`);
waiters.forEach((w) => w.reject(err));
return;
}
} catch (err) {
waiters.forEach((w) => w.reject(err));
return;
}
waiters.forEach((w) => w.resolve(merged));
}
/**
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
* server-side merged document when the debounced PUT eventually fires.
* Multiple rapid calls for the same `key` coalesce into a single PUT;
* a later call's value for a given axis overrides an earlier pending value.
* Calls for different `key`s are isolated.
*/
export function saveUserOverrides(
key: string,
partial: UserOverridesPartial,
): Promise<Partial<UserOverrides>> {
const bucket = getBucket(key);
// Per-axis coalescing — later mutations replace earlier pending values.
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
bucket.partial[axis] = partial[axis] as never;
}
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
bucket.waiters.push({ resolve, reject });
});
if (bucket.timer !== null) clearTimeout(bucket.timer);
bucket.timer = setTimeout(() => {
void flushBucket(key, bucket);
}, DEBOUNCE_MS);
return p;
}
/**
* Force-flush pending debounced writes. With no arg, flushes ALL pending
* keys (used before pipeline runs so the backend reads the latest file).
* With a key, flushes only that key's bucket.
*
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
* are swallowed at the flush level — the original caller's
* saveUserOverrides() promise still rejects to its owner via the waiter.
*/
export async function flushUserOverrides(key?: string): Promise<void> {
const targets: Array<[string, PendingBucket]> = [];
if (key !== undefined) {
const b = buckets.get(key);
if (b && b.timer !== null) targets.push([key, b]);
} else {
buckets.forEach((b, k) => {
if (b.timer !== null) targets.push([k, b]);
});
}
const flushPromises = targets.map(([k, b]) => {
if (b.timer !== null) {
clearTimeout(b.timer);
b.timer = null;
}
return flushBucket(k, b);
});
await Promise.all(flushPromises);
}
/** Test-only — clears all pending buckets without firing PUTs. */
export function __resetUserOverridesBuckets_FOR_TEST(): void {
buckets.forEach((b) => {
if (b.timer !== null) clearTimeout(b.timer);
});
buckets.clear();
}

View File

@@ -1,4 +1,108 @@
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
import type { UserOverrides } from "../services/userOverridesApi";
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
// These helpers compose persisted `user_overrides.json` payloads (typed by
// the u5 service) onto the in-memory `UserSelection`. They live here rather
// than inline in Home.tsx so vitest can drive them in a node environment
// without booting React or pulling in the radix-ui / lucide UI deps that
// Home.tsx requires. Home.tsx wires these into:
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
// seed so handleGenerate's CLI-args build picks them up)
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
// freshly built slidePlan)
// The on-disk schema and clear-sentinel semantics are owned by:
// • src/user_overrides_io.py (KNOWN_AXES, u1)
// • Front/vite.config.ts mergeUserOverrides (u4)
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
// Any KNOWN_AXES drift must land in those files first.
/**
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
* so the same persisted file is read from both ends without translation.
*/
export function deriveUserOverridesKey(filename: string): string {
return filename.replace(/\.mdx$/i, "");
}
const LAYOUT_PRESET_IDS = new Set<string>([
"single",
"horizontal-2",
"vertical-2",
"top-1-bottom-2",
"top-2-bottom-1",
"left-1-right-2",
"left-2-right-1",
"grid-2x2",
]);
/**
* Layer the three non-frame axes from a persisted `user_overrides.json`
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
* shapes are silently ignored — the u5 GET path already returns `{}` on
* corrupt files, but we revalidate here so hand-edited files or future
* forward-compat axes cannot poison the in-memory state.
*
* Frames are NOT layered here because the on-disk key (`unit_id` =
* section_ids joined by `+`) only resolves after the slidePlan zones are
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
*/
export function applyPersistedNonFrameOverrides(
selection: UserSelection,
persisted: Partial<UserOverrides> | null | undefined,
): UserSelection {
if (!persisted || typeof persisted !== "object") return selection;
const next = { ...selection.overrides };
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
next.layout_preset = persisted.layout as LayoutPresetId;
}
if (
persisted.zone_geometries &&
typeof persisted.zone_geometries === "object" &&
!Array.isArray(persisted.zone_geometries)
) {
next.zone_geometries = { ...persisted.zone_geometries };
}
if (
persisted.zone_sections &&
typeof persisted.zone_sections === "object" &&
!Array.isArray(persisted.zone_sections)
) {
next.zone_sections = { ...persisted.zone_sections };
}
return { ...selection, overrides: next };
}
/**
* Remap persisted frames (`unit_id` → template_id) to the in-memory
* `zone_frames` (region.id → template_id) using the freshly built
* slidePlan zones. `unit_id` follows handleGenerate's convention:
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
* matches any zone (e.g. user changed zone_sections between sessions) are
* silently dropped.
*/
export function remapPersistedFramesToZoneFrames(
slidePlan: SlidePlan | null | undefined,
framesByUnitId: Record<string, string> | null | undefined,
): Record<string, string> {
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
return {};
}
const out: Record<string, string> = {};
for (const zone of slidePlan.zones) {
const region = zone.internal_regions[0];
if (!region) continue;
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
const unitId = zone.section_ids.join("+");
const templateId = framesByUnitId[unitId];
if (typeof templateId === "string" && templateId.length > 0) {
out[region.id] = templateId;
}
}
return out;
}
/**
* Phase Z 초기 선택 상태 생성