feat: branch and checkpoint aware bg images

This commit is contained in:
Failerko 2026-02-07 01:40:12 +01:00
parent ba00458f12
commit b3b63d1985
5 changed files with 129 additions and 15 deletions

View file

@ -0,0 +1,21 @@
-- Migration 024: Table-based background images
-- Supports per-branch and per-checkpoint backgrounds
CREATE TABLE IF NOT EXISTS background_images (
id TEXT PRIMARY KEY,
story_id TEXT NOT NULL,
branch_id TEXT, -- NULL for main branch context
checkpoint_id TEXT, -- Linked to a specific checkpoint
image_data TEXT NOT NULL, -- The image data (URL or base64)
created_at INTEGER NOT NULL,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE,
FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE,
FOREIGN KEY (checkpoint_id) REFERENCES checkpoints(id) ON DELETE CASCADE
);
-- Index for fast lookup by story/branch
CREATE INDEX IF NOT EXISTS idx_backgrounds_branch ON background_images(story_id, branch_id);
-- Index for fast lookup by story/checkpoint
CREATE INDEX IF NOT EXISTS idx_backgrounds_checkpoint ON background_images(story_id, checkpoint_id);

View file

@ -1,2 +0,0 @@
-- Add current_background_image column to stories table
ALTER TABLE stories ADD COLUMN current_background_image TEXT;

View file

@ -151,7 +151,7 @@ pub fn run() {
Migration {
version: 24,
description: "story_bg_image",
sql: include_str!("../migrations/024_story_bg_image.sql"),
sql: include_str!("../migrations/024_background_images.sql"),
kind: MigrationKind::Up,
}
];

View file

@ -164,9 +164,8 @@ class DatabaseService {
retry_state,
style_review_state,
time_tracker
current_background_image
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
story.id,
story.title,
@ -181,7 +180,6 @@ class DatabaseService {
story.retryState ? JSON.stringify(story.retryState) : null,
story.styleReviewState ? JSON.stringify(story.styleReviewState) : null,
story.timeTracker ? JSON.stringify(story.timeTracker) : null,
story.currentBgImage,
],
)
return { ...story, createdAt: now, updatedAt: now }
@ -1670,14 +1668,81 @@ class DatabaseService {
}
/**
* Save the current background image for a story.
* Get the background image for a specific branch.
*/
async saveCurrentBackgroundImage(storyId: string, imageData: string | null): Promise<void> {
async getBackgroundForBranch(storyId: string, branchId: string | null): Promise<string | null> {
const db = await this.getDb()
await db.execute('UPDATE stories SET current_background_image = ? WHERE id = ?', [
imageData,
storyId,
])
const results = await db.select<{ image_data: string }[]>(
'SELECT image_data FROM background_images WHERE story_id = ? AND branch_id IS ? AND checkpoint_id IS NULL ORDER BY created_at DESC LIMIT 1',
[storyId, branchId],
)
return results.length > 0 ? results[0].image_data : null
}
/**
* Get the background image for a specific checkpoint.
*/
async getBackgroundForCheckpoint(storyId: string, checkpointId: string): Promise<string | null> {
const db = await this.getDb()
const results = await db.select<{ image_data: string }[]>(
'SELECT image_data FROM background_images WHERE story_id = ? AND checkpoint_id = ? LIMIT 1',
[storyId, checkpointId],
)
return results.length > 0 ? results[0].image_data : null
}
/**
* Save a background image for a story/branch/checkpoint.
*/
async saveBackground(
storyId: string,
branchId: string | null,
checkpointId: string | null,
imageData: string | null,
): Promise<void> {
const db = await this.getDb()
if (!imageData) {
// If clearing, delete entries for this specific context
if (checkpointId) {
await db.execute('DELETE FROM background_images WHERE story_id = ? AND checkpoint_id = ?', [
storyId,
checkpointId,
])
} else {
await db.execute(
'DELETE FROM background_images WHERE story_id = ? AND branch_id IS ? AND checkpoint_id IS NULL',
[storyId, branchId],
)
}
return
}
// Insert or update
const id = crypto.randomUUID()
const now = Date.now()
if (checkpointId) {
// Checkpoints always get a new entry or replace existing for that checkpoint
await db.execute(
'INSERT OR REPLACE INTO background_images (id, story_id, branch_id, checkpoint_id, image_data, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[id, storyId, branchId, checkpointId, imageData, now],
)
} else {
// For branches (including main), we update the single "current" record for that branch
const existing = await this.getBackgroundForBranch(storyId, branchId)
if (existing) {
await db.execute(
'UPDATE background_images SET image_data = ?, created_at = ? WHERE story_id = ? AND branch_id IS ? AND checkpoint_id IS NULL',
[imageData, now, storyId, branchId],
)
} else {
await db.execute(
'INSERT INTO background_images (id, story_id, branch_id, checkpoint_id, image_data, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[id, storyId, branchId, null, imageData, now],
)
}
}
}
/**
@ -1752,7 +1817,7 @@ class DatabaseService {
styleReviewState: row.style_review_state ? JSON.parse(row.style_review_state) : null,
timeTracker: row.time_tracker ? JSON.parse(row.time_tracker) : null,
currentBranchId: row.current_branch_id || null,
currentBgImage: row.current_background_image || null,
currentBgImage: null, // Loaded separately now
}
}

View file

@ -431,7 +431,7 @@ class StoryStore {
await database.cleanupOrphanedEmbeddedImages()
this.currentStory = story
this.currentBgImage = story.currentBgImage
this.currentBgImage = await database.getBackgroundForBranch(storyId, story.currentBranchId)
// Load branch-independent data first
const [characters, locations, items, storyBeats, checkpoints, lorebookEntries, branches] =
@ -745,7 +745,12 @@ class StoryStore {
this.currentStory.currentBgImage = imageData
}
await database.saveCurrentBackgroundImage(this.currentStory.id, imageData)
await database.saveBackground(
this.currentStory.id,
this.currentStory.currentBranchId,
null,
imageData,
)
log('Background image updated and persisted')
}
@ -1915,6 +1920,18 @@ class StoryStore {
await database.createCheckpoint(checkpoint)
this.checkpoints = [checkpoint, ...this.checkpoints]
// Save current background for this checkpoint
if (this.currentBgImage) {
log('Saving background for checkpoint:', name)
await database.saveBackground(
this.currentStory.id,
this.currentStory.currentBranchId,
checkpoint.id,
this.currentBgImage,
)
}
log('Checkpoint created:', name)
// Emit event
@ -2020,6 +2037,16 @@ class StoryStore {
await database.addBranch(branch)
this.branches = [...this.branches, branch]
// Inherit background from checkpoint
const checkpointBg = await database.getBackgroundForCheckpoint(
this.currentStory.id,
checkpointId,
)
if (checkpointBg) {
log('Inheriting background from checkpoint for new branch:', branch.name)
await database.saveBackground(this.currentStory.id, branch.id, null, checkpointBg)
}
// Copy world state from checkpoint into database with the new branch_id
// This ensures the branch has its own copy of the world state at the fork point
log('Copying world state from checkpoint to branch:', branch.name)
@ -2170,6 +2197,9 @@ class StoryStore {
this.invalidateWordCountCache()
this.invalidateChapterCache()
// Reload background from database for the branch
this.currentBgImage = await database.getBackgroundForBranch(this.currentStory.id, branchId)
log('Switched to branch:', branchId ?? 'main')
}