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 { Migration {
version: 24, version: 24,
description: "story_bg_image", description: "story_bg_image",
sql: include_str!("../migrations/024_story_bg_image.sql"), sql: include_str!("../migrations/024_background_images.sql"),
kind: MigrationKind::Up, kind: MigrationKind::Up,
} }
]; ];

View file

@ -164,9 +164,8 @@ class DatabaseService {
retry_state, retry_state,
style_review_state, style_review_state,
time_tracker time_tracker
current_background_image
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
story.id, story.id,
story.title, story.title,
@ -181,7 +180,6 @@ class DatabaseService {
story.retryState ? JSON.stringify(story.retryState) : null, story.retryState ? JSON.stringify(story.retryState) : null,
story.styleReviewState ? JSON.stringify(story.styleReviewState) : null, story.styleReviewState ? JSON.stringify(story.styleReviewState) : null,
story.timeTracker ? JSON.stringify(story.timeTracker) : null, story.timeTracker ? JSON.stringify(story.timeTracker) : null,
story.currentBgImage,
], ],
) )
return { ...story, createdAt: now, updatedAt: now } 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() const db = await this.getDb()
await db.execute('UPDATE stories SET current_background_image = ? WHERE id = ?', [ const results = await db.select<{ image_data: string }[]>(
imageData, '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, [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, styleReviewState: row.style_review_state ? JSON.parse(row.style_review_state) : null,
timeTracker: row.time_tracker ? JSON.parse(row.time_tracker) : null, timeTracker: row.time_tracker ? JSON.parse(row.time_tracker) : null,
currentBranchId: row.current_branch_id || 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() await database.cleanupOrphanedEmbeddedImages()
this.currentStory = story this.currentStory = story
this.currentBgImage = story.currentBgImage this.currentBgImage = await database.getBackgroundForBranch(storyId, story.currentBranchId)
// Load branch-independent data first // Load branch-independent data first
const [characters, locations, items, storyBeats, checkpoints, lorebookEntries, branches] = const [characters, locations, items, storyBeats, checkpoints, lorebookEntries, branches] =
@ -745,7 +745,12 @@ class StoryStore {
this.currentStory.currentBgImage = imageData 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') log('Background image updated and persisted')
} }
@ -1915,6 +1920,18 @@ class StoryStore {
await database.createCheckpoint(checkpoint) await database.createCheckpoint(checkpoint)
this.checkpoints = [checkpoint, ...this.checkpoints] 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) log('Checkpoint created:', name)
// Emit event // Emit event
@ -2020,6 +2037,16 @@ class StoryStore {
await database.addBranch(branch) await database.addBranch(branch)
this.branches = [...this.branches, 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 // 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 // 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) log('Copying world state from checkpoint to branch:', branch.name)
@ -2170,6 +2197,9 @@ class StoryStore {
this.invalidateWordCountCache() this.invalidateWordCountCache()
this.invalidateChapterCache() this.invalidateChapterCache()
// Reload background from database for the branch
this.currentBgImage = await database.getBackgroundForBranch(this.currentStory.id, branchId)
log('Switched to branch:', branchId ?? 'main') log('Switched to branch:', branchId ?? 'main')
} }