check point

This commit is contained in:
dimitar committed 2026-05-22 03:51:50 +02:00
1 parent 33c70e776a
commit b0f9acdb47
37 files changed
+4469 -177

No files matched your search

+113 -3
View File
@@ -327,7 +327,7 @@ validate_generate_script_options :: proc(opts: Generate_Script_Options) -> share
build_fallback_script :: proc(opts: Generate_Script_Options) -> core.Comic_Script {
dialogues: [1]core.Dialogue = [1]core.Dialogue{
{speaker_id = "char_001", text = "Let's begin.", bubble_type = .Normal, emotion = "neutral"},
{speaker_id = "char_001", text = "Let's begin.", bubble_type = .Normal, emotion = .Neutral},
}
chars_present: [1]string = [1]string{"char_001"}
panels: [1]core.Panel = [1]core.Panel{
@@ -576,7 +576,7 @@ convert_raw_script :: proc(raw: Raw_Script) -> core.Comic_Script {
speaker_id = strings.clone(d.speakerId, context.allocator),
text = strings.clone(d.text, context.allocator),
bubble_type = bubble_type_from_string(d.bubbleType),
emotion = strings.clone(d.emotion, context.allocator),
emotion = core.parse_emotion(d.emotion),
})
}
@@ -630,7 +630,7 @@ convert_raw_script_alt :: proc(raw: Raw_Script_Alt) -> core.Comic_Script {
speaker_id = strings.clone(d.speaker_id, context.allocator),
text = strings.clone(d.text, context.allocator),
bubble_type = bubble_type_from_string(d.bubble_type),
emotion = strings.clone(d.emotion, context.allocator),
emotion = core.parse_emotion(d.emotion),
})
}
@@ -787,3 +787,113 @@ generate_comic_script_stub :: proc(cfg: shared.Config, opts: Generate_Script_Opt
client := new_deepseek_client()
return generate_comic_script(client, cfg, opts)
}
Stream_Callback :: #type proc(chunk: string, is_complete: bool) -> ()
stream_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: Generate_Script_Options, callback: Stream_Callback) -> (core.Comic_Script, shared.App_Error) {
if len(cfg.deepseek_api_key) == 0 {
return core.Comic_Script{}, shared.config_error("DEEPSEEK_API_KEY is missing")
}
if verr := validate_generate_script_options(opts); !shared.is_ok(verr) {
return core.Comic_Script{}, verr
}
url := fmt.aprintf("%s/chat/completions", cfg.deepseek_base_url)
auth := fmt.aprintf("Authorization: Bearer %s", cfg.deepseek_api_key)
user_content := fmt.aprintf(
"Create a %d-page comic script. Idea: %s. Genre: %s. Art Style: %s. Audience: %s. Return valid JSON.",
opts.num_pages,
opts.story_idea,
opts.genre,
opts.art_style,
opts.audience,
)
defer delete(user_content)
body_json := fmt.aprintf(
"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an expert comic writer. Return JSON only.\"},{\"role\":\"user\",\"content\":\"%s\"}],\"response_format\":{\"type\":\"json_object\"},\"temperature\":0.8,\"stream\":true}",
deepseek_json_escape(user_content),
)
defer delete(body_json)
cmd := [12]string{
"curl", "-sS", "-X", "POST", url,
"-H", "Content-Type: application/json",
"-H", auth,
"-d", body_json,
"--no-buffer",
}
desc := os.Process_Desc{command = cmd[:]}
state, stdout, stderr, exec_err := os.process_exec(desc, context.temp_allocator)
if exec_err != nil {
return core.Comic_Script{}, shared.network_error(fmt.aprintf("curl streaming failed: %v", exec_err))
}
if !state.exited || state.exit_code != 0 {
return core.Comic_Script{}, shared.network_error(fmt.aprintf("curl streaming exited with error: %s", string(stderr)))
}
output := string(stdout)
defer delete(output)
// Parse SSE stream chunks
accumulated: [dynamic]u8
lines: [dynamic]string
current_line: [dynamic]u8
for c in output {
if c == '\n' {
append(&lines, string(current_line[:]))
delete(current_line)
} else {
append(&accumulated, u8(c))
}
}
if len(current_line) > 0 {
append(&lines, string(current_line[:]))
}
defer delete(lines)
for line in lines {
trimmed := strings.trim_space(line)
if len(trimmed) == 0 || !strings.has_prefix(trimmed, "data:") {
continue
}
data := strings.trim_space(trimmed[5:])
if data == "[DONE]" {
if callback != nil {
callback("", true)
}
break
}
for b in data {
append(&accumulated, u8(b))
}
if callback != nil {
callback(data, false)
}
}
accumulated_str := string(accumulated[:])
defer delete(accumulated_str)
// Parse the accumulated response
if len(accumulated_str) == 0 {
return core.Comic_Script{}, shared.generation_error("streaming produced no content")
}
script, perr := parse_deepseek_script_response(accumulated_str)
if shared.is_ok(perr) {
return script, shared.ok()
}
if strings.has_prefix(perr.message, "normalized script failed minimal validation") {
fallback := build_fallback_script(opts)
return fallback, shared.ok()
}
return core.Comic_Script{}, perr
}
stream_comic_script_stub :: proc(cfg: shared.Config, opts: Generate_Script_Options, callback: Stream_Callback) -> (core.Comic_Script, shared.App_Error) {
client := new_deepseek_client()
return stream_comic_script(client, cfg, opts, callback)
}
+237 -57
View File
@@ -20,6 +20,14 @@ Ordered_Panel :: struct {
panel_id: string,
}
Panel_Render_Entry :: struct {
path: string,
px: int,
py: int,
pw: int,
ph: int,
}
collect_ordered_panels :: proc(layouts: []core.Page_Layout) -> []Ordered_Panel {
panels: [dynamic]Ordered_Panel
for page in layouts {
@@ -77,6 +85,145 @@ file_ext_from_url :: proc(url: string) -> string {
return ext
}
download_or_copy_panel :: proc(url: string, out_path: string) -> shared.App_Error {
if strings.has_prefix(url, "file://") {
src_path := url[len("file://"):]
if cerr := os.copy_file(out_path, src_path); cerr != nil {
msg := fmt.aprintf("failed to copy local panel image: %v", cerr)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
} else {
cmd := [6]string{"curl", "-L", "-sS", "-o", out_path, url}
if cerr := run_command(cmd[:]); !shared.is_ok(cerr) {
msg := fmt.aprintf("failed to download panel image: %s", cerr.message)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
}
return shared.ok()
}
get_page_dimensions :: proc(page_size: core.Page_Size_Name) -> (int, int) {
switch page_size {
case .A4: return 2480, 3508
case .Letter: return 2550, 3300
case .Manga: return 2158, 3035
case .Webtoon: return 800, 1280
case .Square: return 1080, 1080
}
return 2480, 3508
}
render_page_to_image :: proc(
layout: core.Page_Layout,
panel_images: map[string]core.Panel_Image,
temp_dir: string,
page_idx: int,
page_size: core.Page_Size_Name,
) -> (string, shared.App_Error) {
page_w, page_h := get_page_dimensions(page_size)
out_path := fmt.aprintf("%s/page_%03d.png", temp_dir, page_idx+1)
defer delete(out_path)
// Collect panel image paths and their positions
entries: [dynamic]Panel_Render_Entry
defer delete(entries)
for p in layout.panels {
img, has := panel_images[p.panel_id]
if !has || len(img.url) == 0 {
continue
}
// Download/copy panel to temp
ext := file_ext_from_url(img.url)
panel_tmp := fmt.aprintf("%s/panel_%s%s", temp_dir, p.panel_id, ext)
defer delete(panel_tmp)
if derr := download_or_copy_panel(img.url, panel_tmp); !shared.is_ok(derr) {
return "", derr
}
// Calculate pixel position from layout cell fractions
gutter := 4
px := int(p.layout_cell.x * f32(page_w)) + gutter
py := int(p.layout_cell.y * f32(page_h)) + gutter
pw := int(p.layout_cell.w * f32(page_w)) - gutter * 2
ph := int(p.layout_cell.h * f32(page_h)) - gutter * 2
if pw <= 0 || ph <= 0 {
continue
}
// Resize panel to fit cell
resized := fmt.aprintf("%s/panel_%s_resized.png", temp_dir, p.panel_id)
defer delete(resized)
resize_cmd: [dynamic]string
append(&resize_cmd, "magick")
append(&resize_cmd, panel_tmp)
append(&resize_cmd, "-resize")
append(&resize_cmd, fmt.aprintf("%dx%d!", pw, ph))
append(&resize_cmd, resized)
defer delete(resize_cmd)
if rerr := run_command(resize_cmd[:]); !shared.is_ok(rerr) {
msg := fmt.aprintf("failed to resize panel %s: %s", p.panel_id, rerr.message)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return "", err_out
}
append(&entries, Panel_Render_Entry{path = resized, px = px, py = py, pw = pw, ph = ph})
}
if len(entries) == 0 {
// No panels to render, just create blank page
blank_cmd: [dynamic]string
append(&blank_cmd, "magick")
append(&blank_cmd, "-size")
append(&blank_cmd, fmt.aprintf("%dx%d", page_w, page_h))
append(&blank_cmd, "xc:white")
append(&blank_cmd, out_path)
defer delete(blank_cmd)
if err := run_command(blank_cmd[:]); !shared.is_ok(err) {
msg := fmt.aprintf("failed to create blank page: %s", err.message)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return "", err_out
}
result := strings.clone(out_path)
return result, shared.ok()
}
// Build composite command: background + overlaid panels
composite_cmd: [dynamic]string
defer delete(composite_cmd)
append(&composite_cmd, "magick")
append(&composite_cmd, "-size")
append(&composite_cmd, fmt.aprintf("%dx%d", page_w, page_h))
append(&composite_cmd, "xc:white")
for e in entries {
append(&composite_cmd, e.path)
append(&composite_cmd, "-geometry")
append(&composite_cmd, fmt.aprintf("+%d+%d", e.px, e.py))
append(&composite_cmd, "-composite")
}
append(&composite_cmd, out_path)
if err := run_command(composite_cmd[:]); !shared.is_ok(err) {
msg := fmt.aprintf("failed to render page %d: %s", page_idx+1, err.message)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return "", err_out
}
result := strings.clone(out_path)
return result, shared.ok()
}
stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_images: map[string]core.Panel_Image) -> shared.App_Error {
staged_count := 0
for p, idx in ordered {
@@ -93,25 +240,9 @@ stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_ima
return jerr
}
if strings.has_prefix(img.url, "file://") {
src_path := img.url[len("file://"):]
if cerr := os.copy_file(out_path, src_path); cerr != nil {
msg := fmt.aprintf("failed to copy local panel image %s: %v", p.panel_id, cerr)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
delete(out_path)
return err_out
}
} else {
cmd := [6]string{"curl", "-L", "-sS", "-o", out_path, img.url}
cerr := run_command(cmd[:])
if !shared.is_ok(cerr) {
msg := fmt.aprintf("failed to download panel %s: %s", p.panel_id, cerr.message)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
delete(out_path)
return err_out
}
if derr := download_or_copy_panel(img.url, out_path); !shared.is_ok(derr) {
delete(out_path)
return derr
}
delete(out_path)
@@ -151,37 +282,6 @@ zip_directory_with_python :: proc(output_path, source_dir: string, include_comic
return run_command(cmd[:])
}
write_simple_pdf :: proc(output_path: string, ordered: []Ordered_Panel) -> shared.App_Error {
text := fmt.aprintf("Comic Export - Panels: %d", len(ordered))
defer delete(text)
content := fmt.aprintf("BT /F1 12 Tf 50 780 Td (%s) Tj ET", text)
defer delete(content)
stream := fmt.aprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content)
defer delete(stream)
obj1 := "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
obj2 := "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
obj3 := "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n"
obj4 := fmt.aprintf("4 0 obj\n%s\nendobj\n", stream)
defer delete(obj4)
obj5 := "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"
xref_start := len("%PDF-1.4\n") + len(obj1) + len(obj2) + len(obj3) + len(obj4) + len(obj5)
pdf := fmt.aprintf(
"%%PDF-1.4\n%s%s%s%s%sxref\n0 6\n0000000000 65535 f \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n",
obj1, obj2, obj3, obj4, obj5, xref_start,
)
defer delete(pdf)
if err := os.write_entire_file(output_path, pdf); err != nil {
msg := fmt.aprintf("failed to write pdf: %v", err)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
return shared.ok()
}
ensure_export_output_parent_dir :: proc(output_path: string) -> shared.App_Error {
dir, _ := filepath.split(output_path)
if len(dir) == 0 {
@@ -207,15 +307,58 @@ export_comic :: proc(output_path: string, layouts: []core.Page_Layout, panel_ima
return derr
}
ordered := collect_ordered_panels(layouts)
defer delete(ordered)
if len(ordered) == 0 {
return shared.new_error(.Export, "no panels available for export", false)
// Check we have at least one panel image
has_images := false
for _, img in panel_images {
if len(img.url) > 0 {
has_images = true
break
}
}
if !has_images {
return shared.new_error(.Export, "no panel images available for export", false)
}
switch opts.format {
case .PDF:
return write_simple_pdf(output_path, ordered)
// Render each page to image, then combine into PDF
temp_dir, terr := os.make_directory_temp("", "comic-pdf-*", context.temp_allocator)
if terr != nil {
msg := fmt.aprintf("failed to create temp dir: %v", terr)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
defer os.remove_all(temp_dir)
page_paths: [dynamic]string
defer delete(page_paths)
for layout, i in layouts {
page_path, rerr := render_page_to_image(layout, panel_images, temp_dir, i, opts.page_size)
if !shared.is_ok(rerr) {
return rerr
}
append(&page_paths, page_path)
}
// Combine page images into PDF using ImageMagick
cmd: [dynamic]string
defer delete(cmd)
append(&cmd, "magick")
for pp in page_paths {
append(&cmd, pp)
}
append(&cmd, output_path)
if err := run_command(cmd[:]); !shared.is_ok(err) {
msg := fmt.aprintf("failed to create PDF: %s", err.message)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
return shared.ok()
case .CBZ, .PNG:
temp_dir, terr := os.make_directory_temp("", "comic-export-*", context.temp_allocator)
if terr != nil {
@@ -226,8 +369,45 @@ export_comic :: proc(output_path: string, layouts: []core.Page_Layout, panel_ima
}
defer os.remove_all(temp_dir)
if serr := stage_panel_images(temp_dir, ordered, panel_images); !shared.is_ok(serr) {
return serr
// Render each page to a composed image
for layout, i in layouts {
page_path, rerr := render_page_to_image(layout, panel_images, temp_dir, i, opts.page_size)
if !shared.is_ok(rerr) {
return rerr
}
defer delete(page_path)
// Rename to page_XXX format
ext := ".png"
if opts.format == .CBZ {
ext = ".jpg"
}
dest := fmt.aprintf("%s/page_%03d%s", temp_dir, int(i)+1, ext)
defer delete(dest)
// Convert to appropriate format if needed
if opts.format == .CBZ {
// Convert PNG to JPEG for CBZ
quality_str := fmt.aprintf("%d", opts.quality)
convert_cmd: [dynamic]string
append(&convert_cmd, "magick")
append(&convert_cmd, page_path)
append(&convert_cmd, "-quality")
append(&convert_cmd, quality_str)
append(&convert_cmd, dest)
defer delete(convert_cmd)
if cerr := run_command(convert_cmd[:]); !shared.is_ok(cerr) {
return cerr
}
} else {
// Just rename for PNG
if rerr := os.rename(page_path, dest); rerr != nil {
msg := fmt.aprintf("failed to rename page image: %v", rerr)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
}
}
include_comic_info := opts.format == .CBZ
+173 -12
View File
@@ -7,7 +7,7 @@ import "core:strings"
import "../core"
import "../shared"
Fal_Transport :: #type proc(cfg: shared.Config, endpoint: string, prompt: string, seed: i64) -> (image_url: string, status_code: int, err: shared.App_Error)
Fal_Transport :: #type proc(cfg: shared.Config, endpoint: string, prompt: string, negative_prompt: string, seed: i64, image_size: string, reference_images: []string, reference_strength: f32) -> (image_url: string, status_code: int, err: shared.App_Error)
Fal_Generation_Queue :: struct {
max_concurrency: int,
@@ -119,10 +119,42 @@ fal_json_escape :: proc(s: string) -> string {
return string(out[:])
}
default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt: string, seed: i64) -> (string, int, shared.App_Error) {
default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt, negative_prompt: string, seed: i64, image_size: string, reference_images: []string, reference_strength: f32) -> (string, int, shared.App_Error) {
url := fmt.aprintf("https://fal.run/fal-ai/%s", endpoint)
auth := fmt.aprintf("Authorization: Key %s", cfg.fal_api_key)
payload := fmt.aprintf("{\"prompt\":\"%s\",\"seed\":%d}", fal_json_escape(prompt), seed)
// Build reference_images JSON array
ref_json := ""
if len(reference_images) > 0 && reference_strength > 0 {
ref_items: [dynamic]u8
append(&ref_items, '[')
for i in 0..<len(reference_images) {
if i > 0 {
append(&ref_items, ',')
}
escaped := fal_json_escape(reference_images[i])
item_str := fmt.aprintf("{\"url\":\"%s\"}", escaped)
for b in item_str {
append(&ref_items, u8(b))
}
delete(item_str)
}
append(&ref_items, ']')
ref_json = fmt.aprintf(",\"reference_images\":%s,\"reference_image_strength\":%.2f", string(ref_items[:]), reference_strength)
defer delete(ref_items)
}
neg_json := ""
if len(negative_prompt) > 0 {
neg_json = fmt.aprintf(",\"negative_prompt\":\"%s\"", fal_json_escape(negative_prompt))
}
size_json := ""
if len(image_size) > 0 {
size_json = fmt.aprintf(",\"image_size\":\"%s\"", fal_json_escape(image_size))
}
payload := fmt.aprintf("{\"prompt\":\"%s\"%s%s%s,\"seed\":%d}", fal_json_escape(prompt), neg_json, size_json, ref_json, seed)
cmd := [13]string{
"curl", "-sS", "-X", "POST", url,
@@ -198,7 +230,9 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c:
}
defer release_slot(client.queue)
prompt := core.build_character_prompt(c, "standing in neutral pose", "clean background", "studio lighting", art_style)
style_key := core.parse_art_style_key(art_style)
style_keywords := core.get_style_keywords(style_key)
prompt := fmt.aprintf("%s. %s, standing in neutral pose facing camera, character portrait, clean background, studio lighting, white background, front-facing portrait, centered composition. %s", style_keywords, core.build_character_prompt(c, "", "", "", ""), core.QUALITY_MODIFIER)
attempts := client.max_retries
if attempts < 1 {
@@ -207,7 +241,7 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c:
last_err := shared.generation_error("unknown fal character generation error")
for attempt in 1..=attempts {
url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, c.seed)
url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, core.NEGATIVE_PROMPT_CHARACTER, c.seed, "square_hd", nil, 0)
if !shared.is_ok(transport_err) {
last_err = transport_err
} else if status_code >= 400 {
@@ -240,9 +274,49 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core
}
defer release_slot(client.queue)
_ = characters
seed := core.generate_panel_seed(project_id, 1, panel.panel_number, panel.panel_id)
prompt := fmt.aprintf("%s comic panel. %s", art_style, panel.description)
// Build prompt with art style keywords
style_key := core.parse_art_style_key(art_style)
style_keywords := core.get_style_keywords(style_key)
// Build character descriptions
char_desc: [dynamic]u8
for char_id in panel.characters_present {
for c in characters {
if c.id == char_id {
if len(char_desc) > 0 {
append(&char_desc, ' ')
}
tmpl := c.prompt_template
desc := fmt.aprintf("%s %s with %s %s hair, %s eyes, %s skin, %s build, wearing %s", tmpl.age, tmpl.gender, tmpl.hair_color, tmpl.hair_style, tmpl.eye_color, tmpl.skin_tone, tmpl.body_type, tmpl.outfit)
for b in desc {
append(&char_desc, u8(b))
}
delete(desc)
break
}
}
}
char_str := string(char_desc[:])
defer delete(char_desc)
// Shot type for sizing
image_size := core.sdxl_size_name(core.get_image_size_for_shot_type(panel.shot_type))
// Collect reference images from characters
ref_images: [dynamic]string
for char_id in panel.characters_present {
for c in characters {
if c.id == char_id && len(c.reference_image_url) > 0 {
append(&ref_images, c.reference_image_url)
break
}
}
}
defer delete(ref_images)
prompt := fmt.aprintf("%s comic panel. %s. Characters: %s. %s shot. %s", style_keywords, panel.description, char_str, panel.shot_type, core.QUALITY_MODIFIER)
attempts := client.max_retries
if attempts < 1 {
@@ -251,7 +325,7 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core
last_err := shared.generation_error("unknown fal panel generation error")
for attempt in 1..=attempts {
url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, seed)
url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, core.NEGATIVE_PROMPT_PANEL, seed, image_size, ref_images[:], 0.65)
if !shared.is_ok(transport_err) {
last_err = transport_err
} else if status_code >= 400 {
@@ -259,7 +333,9 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core
} else if len(url) == 0 {
last_err = shared.generation_error("fal returned empty image url")
} else {
return core.Panel_Image{url = url, width = 1024, height = 1024, seed = seed, prompt = prompt}, shared.ok()
// Return dimensions from response (we'll use the image_size preset to estimate)
w, h := dimensions_from_sdxl_size(image_size)
return core.Panel_Image{url = url, width = w, height = h, seed = seed, prompt = prompt}, shared.ok()
}
if attempt < attempts && shared.should_retry(last_err) {
@@ -272,15 +348,30 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core
return core.Panel_Image{}, last_err
}
generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id: string) -> (map[string]core.Panel_Image, shared.App_Error) {
results := make(map[string]core.Panel_Image)
dimensions_from_sdxl_size :: proc(image_size: string) -> (int, int) {
switch image_size {
case "landscape_16_9": return 1344, 768
case "landscape_4_3": return 1152, 896
case "portrait_4_3": return 896, 1152
case "square_hd": return 1024, 1024
case "portrait_16_9": return 768, 1344
}
return 1024, 1024
}
for p in panels {
generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id: string, progress_callback: #type proc(current, total: int) -> ()) -> (map[string]core.Panel_Image, shared.App_Error) {
results := make(map[string]core.Panel_Image)
total := len(panels)
for p, idx in panels {
img, err := generate_panel_image(client, cfg, p, characters, art_style, project_id)
if !shared.is_ok(err) {
return results, err
}
results[p.panel_id] = img
if progress_callback != nil {
progress_callback(idx+1, total)
}
}
return results, shared.ok()
@@ -292,6 +383,76 @@ generate_character_reference_stub :: proc(cfg: shared.Config, c: core.Character,
return generate_character_reference(client, cfg, c, art_style)
}
Character_Sheet_Pose :: struct {
name: string,
prompt_suffix: string,
}
CHARACTER_SHEET_POSES :: [4]Character_Sheet_Pose{
Character_Sheet_Pose{name = "front", prompt_suffix = "front-facing portrait, looking at camera"},
Character_Sheet_Pose{name = "three-quarter", prompt_suffix = "three-quarter view portrait, slightly turned"},
Character_Sheet_Pose{name = "profile", prompt_suffix = "side profile view, facing left"},
Character_Sheet_Pose{name = "back", prompt_suffix = "back view showing hair and outfit from behind"},
}
generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style: string) -> ([]string, shared.App_Error) {
if len(cfg.fal_api_key) == 0 {
return nil, shared.config_error("FAL_API_KEY is missing")
}
if client.queue == nil {
return nil, shared.config_error("fal queue is not configured")
}
style_key := core.parse_art_style_key(art_style)
style_keywords := core.get_style_keywords(style_key)
sheet_urls: [dynamic]string
all_failed := true
for pose, i in CHARACTER_SHEET_POSES {
if !try_acquire_slot(client.queue) {
return nil, shared.generation_error("fal queue saturated")
}
pose_seed := c.seed + i64(i)
// Build prompt for this pose
base_desc := core.build_character_prompt(c, "", "", "", "")
prompt := fmt.aprintf("%s. %s, %s, character sheet, clean background, studio lighting, white background, centered composition. %s", style_keywords, base_desc, pose.prompt_suffix, core.QUALITY_MODIFIER)
// Use first image as reference for subsequent poses
ref_images: [dynamic]string
if len(sheet_urls) > 0 {
append(&ref_images, sheet_urls[0])
}
defer delete(ref_images)
url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, core.NEGATIVE_PROMPT_CHARACTER, pose_seed, "square_hd", ref_images[:], 0.65)
release_slot(client.queue)
if shared.is_ok(transport_err) && status_code < 400 && len(url) > 0 {
append(&sheet_urls, url)
all_failed = false
}
}
if all_failed {
for u in sheet_urls {
delete(u)
}
delete(sheet_urls)
return nil, shared.generation_error("all character sheet poses failed")
}
return sheet_urls[:], shared.ok()
}
generate_character_sheet_stub :: proc(cfg: shared.Config, c: core.Character, art_style: string) -> ([]string, shared.App_Error) {
q := new_fal_queue(2)
client := new_fal_client(&q)
return generate_character_sheet(client, cfg, c, art_style)
}
generate_panel_image_stub :: proc(cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) {
q := new_fal_queue(2)
client := new_fal_client(&q)
+22 -6
View File
@@ -219,7 +219,7 @@ build_local_script :: proc(story_idea: string, pages: int) -> core.Comic_Script
chars_present := make([]string, 1)
chars_present[0] = "char_001"
dialogue := make([]core.Dialogue, 1)
dialogue[0] = core.Dialogue{speaker_id = "char_001", text = "Let's do this.", bubble_type = .Normal, emotion = "neutral"}
dialogue[0] = core.Dialogue{speaker_id = "char_001", text = "Let's do this.", bubble_type = .Normal, emotion = .Neutral}
panel := core.Panel{
panel_id = local_panel_id_by_index(i),
@@ -287,13 +287,29 @@ build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel
name := fmt.aprintf("panel_%03d_%s.png", idx+1, p.panel_id)
out_path := fmt.aprintf("%s/%s", tmp_dir, name)
delete(name)
if werr := os.write_entire_file(out_path, "LOCAL PANEL IMAGE"); werr != nil {
// Create a real PNG image using python3
gen_w := 1024
gen_h := 1024
py_script := fmt.aprintf(
"import struct,zlib,sys;w,h=%d,%d;rows=[]\nfor _ in range(h): rows.append(b'\\x00'+b'\\xff\\xff\\xff'*w)\nraw=b''.join(rows);comp=zlib.compress(raw)\ndef crc(d): return struct.pack('>I',zlib.crc32(d)&0xffffffff)\nf=open(sys.argv[1],'wb')\nf.write(b'\\x89PNG\\r\\n\\x1a\\n')\nihdr_data=struct.pack('>IIBBBBB',w,h,8,2,0,0,0)\nf.write(struct.pack('>I',13)+b'IHDR'+ihdr_data+crc(b'IHDR'+ihdr_data))\nf.write(struct.pack('>I',len(comp))+b'IDAT'+comp+crc(b'IDAT'+comp))\nf.write(struct.pack('>I',0)+b'IEND'+crc(b'IEND'))\nf.close()",
gen_w, gen_h,
)
defer delete(py_script)
py_cmd := [4]string{"python3", "-c", py_script, out_path}
desc := os.Process_Desc{command = py_cmd[:]}
state, _, stderr, cerr := os.process_exec(desc, context.temp_allocator)
if cerr != nil || !state.exited || state.exit_code != 0 {
delete(out_path)
return nil, shared.new_error(.Generation, "failed writing local panel image", true)
msg := fmt.aprintf("failed to create panel image: %s", string(stderr))
defer delete(msg)
return nil, shared.new_error(.Generation, msg, true)
}
url := fmt.aprintf("file://%s", out_path)
prompt := fmt.aprintf("local")
images[p.panel_id] = core.Panel_Image{url = url, width = 1024, height = 1024, seed = i64(idx + 1), prompt = prompt}
prompt := fmt.aprintf("local panel %d", idx+1)
images[p.panel_id] = core.Panel_Image{url = url, width = gen_w, height = gen_h, seed = i64(idx + 1), prompt = prompt}
delete(out_path)
}
return images, shared.ok()
@@ -873,7 +889,7 @@ run_tui_command :: proc(controller: ^ui.App_Controller, input: string, last_job_
q := adapters.new_fal_queue(2)
client := adapters.new_fal_client(&q)
images, gerr := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, controller.state.project.project_id)
images, gerr := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, controller.state.project.project_id, nil)
if !shared.is_ok(gerr) {
controller.state.workflow.is_generating = false
controller.state.workflow.error_message = gerr.message
+18
View File
@@ -161,3 +161,21 @@ auto_place_panel_bubbles :: proc(panel: Panel, panel_w, panel_h: f32) -> []Speec
return bubbles[:]
}
update_bubble_text :: proc(bubble: Speech_Bubble, new_text: string) -> Speech_Bubble {
updated := bubble
updated.text = new_text
return updated
}
update_bubble_position :: proc(bubble: Speech_Bubble, x, y: f32) -> Speech_Bubble {
updated := bubble
updated.position = Position{x = x, y = y}
return updated
}
reset_bubble_position :: proc(bubble: Speech_Bubble) -> Speech_Bubble {
updated := bubble
updated.position = Position{x = 0, y = 0}
return updated
}
+233
View File
@@ -0,0 +1,233 @@
package core
import "core:strings"
import "core:fmt"
CHARACTER_TEMPLATE_STRING :: "{age}-year-old {gender}, {hairColor} {hairStyle} hair, {eyeColor} eyes, {skinTone} skin, {bodyType} build, wearing {outfit}, {accessories}, {distinguishingFeatures}"
extract_age :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
// Try digit patterns first
i: int = 0
for i < len(lower)-3 {
if lower[i] >= '0' && lower[i] <= '9' {
start := i
for i < len(lower) && lower[i] >= '0' && lower[i] <= '9' {
i += 1
}
rest := lower[i:]
if strings.has_prefix(rest, "-year-old") || strings.has_prefix(rest, "-year") || strings.has_prefix(rest, " years") || strings.has_prefix(rest, " year") {
return strings.clone(lower[start:i])
}
}
i += 1
}
// Try word patterns
word_ages := []string{"young", "teen", "adult", "middle-aged", "middle aged", "elderly", "old"}
for wa in word_ages {
if strings.contains(lower, wa) {
return strings.clone(wa)
}
}
return ""
}
extract_gender :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
male_terms := []string{"male", "man", "boy", "guy", "he", "him"}
female_terms := []string{"female", "woman", "girl", "lady", "she", "her"}
nb_terms := []string{"non-binary", "nonbinary", "nb", "enby"}
for t in nb_terms {
if strings.contains(lower, t) { return strings.clone("non-binary") }
}
for t in female_terms {
if strings.contains(lower, t) { return strings.clone("female") }
}
for t in male_terms {
if strings.contains(lower, t) { return strings.clone("male") }
}
return ""
}
extract_hair_color :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
colors := []string{"black", "brown", "blonde", "blond", "red", "auburn", "brunette", "silver", "gray", "grey", "white", "blue", "green", "purple", "pink", "orange", "golden", "platinum", "chestnut", "strawberry"}
for c in colors {
if strings.contains(lower, c) {
return strings.clone(c)
}
}
return ""
}
extract_hair_style :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
styles := []string{"long", "short", "medium", "curly", "straight", "wavy", "spiky", "spikey", "ponytail", "bun", "braid", "buzz cut", "bald", "mohawk", "pixie", "bob", "afro", "dreadlocks", "dreads", "crew cut", "layered"}
for s in styles {
if strings.contains(lower, s) {
return strings.clone(s)
}
}
return ""
}
extract_eye_color :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
colors := []string{"blue", "brown", "green", "hazel", "amber", "gray", "grey", "violet", "purple", "black", "red"}
for c in colors {
if strings.contains(lower, c) {
return strings.clone(c)
}
}
return ""
}
extract_skin_tone :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
tones := []string{"fair", "pale", "light", "medium", "tan", "olive", "brown", "dark", "ebony", "porcelain", "bronze", "caramel", "warm", "cool"}
for t in tones {
if strings.contains(lower, t) {
return strings.clone(t)
}
}
return ""
}
extract_body_type :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
types := []string{"slim", "skinny", "thin", "average", "athletic", "muscular", "heavy", "large", "tall", "short", "petite", "stocky", "lean", "broad", "curvy", "slender", "chubby", "fit"}
for t in types {
if strings.contains(lower, t) {
return strings.clone(t)
}
}
return ""
}
extract_outfit :: proc(description: string) -> string {
lower := strings.to_lower(description)
// Try "wearing X" pattern
if idx := strings.index(lower, "wearing "); idx >= 0 {
rest := lower[idx+len("wearing "):]
end := len(rest)
if comma := strings.index(rest, ","); comma >= 0 { end = comma }
if period := strings.index(rest, "."); period >= 0 && period < end { end = period }
return strings.clone(rest[:end])
}
// Try "dressed in X" pattern
if idx := strings.index(lower, "dressed in "); idx >= 0 {
rest := lower[idx+len("dressed in "):]
end := len(rest)
if comma := strings.index(rest, ","); comma >= 0 { end = comma }
return strings.clone(rest[:end])
}
return ""
}
extract_accessories :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
accessories := []string{"glasses", "sunglasses", "hat", "cap", "beanie", "scarf", "necklace", "pendant", "earrings", "bracelet", "watch", "ring", "backpack", "bag", "gloves", "belt", "tie", "bow", "crown", "headband"}
found: [dynamic]string
for a in accessories {
if strings.contains(lower, a) {
append(&found, a)
}
}
if len(found) == 0 {
return ""
}
result := strings.join(found[:], ", ")
defer delete(found)
return result
}
extract_distinguishing_features :: proc(description: string) -> string {
lower := strings.to_lower(description)
defer delete(lower)
features := []string{"scar", "scars", "tattoo", "tattoos", "freckles", "mole", "birthmark", "beard", "mustache", "goatee", "monocle", "prosthetic", "piercing", "piercings", "dimples", "cleft chin"}
found: [dynamic]string
for f in features {
if strings.contains(lower, f) {
append(&found, f)
}
}
if len(found) == 0 {
return ""
}
result := strings.join(found[:], ", ")
defer delete(found)
return result
}
parse_description_to_template :: proc(description: string) -> Character_Prompt_Template {
return Character_Prompt_Template{
age = extract_age(description),
gender = extract_gender(description),
hair_color = extract_hair_color(description),
hair_style = extract_hair_style(description),
eye_color = extract_eye_color(description),
skin_tone = extract_skin_tone(description),
body_type = extract_body_type(description),
outfit = extract_outfit(description),
accessories = extract_accessories(description),
distinguishing_features = extract_distinguishing_features(description),
}
}
extract_color_palette :: proc(description: string) -> Color_Palette {
return Color_Palette{
hair = extract_hair_color(description),
eyes = extract_eye_color(description),
skin = extract_skin_tone(description),
outfit = extract_outfit(description),
}
}
template_to_string :: proc(t: Character_Prompt_Template) -> string {
parts: [dynamic]string
if len(t.age) > 0 { append(&parts, fmt.tprintf("%s-year-old", t.age)) }
if len(t.gender) > 0 { append(&parts, t.gender) }
if len(t.hair_color) > 0 || len(t.hair_style) > 0 {
hair := ""
if len(t.hair_color) > 0 { hair = t.hair_color }
if len(t.hair_style) > 0 {
if len(hair) > 0 { hair = fmt.tprintf("%s %s", hair, t.hair_style) }
else { hair = t.hair_style }
}
append(&parts, fmt.tprintf("%s hair", hair))
}
if len(t.eye_color) > 0 { append(&parts, fmt.tprintf("%s eyes", t.eye_color)) }
if len(t.skin_tone) > 0 { append(&parts, fmt.tprintf("%s skin", t.skin_tone)) }
if len(t.body_type) > 0 { append(&parts, fmt.tprintf("%s build", t.body_type)) }
if len(t.outfit) > 0 { append(&parts, fmt.tprintf("wearing %s", t.outfit)) }
if len(t.accessories) > 0 { append(&parts, t.accessories) }
if len(t.distinguishing_features) > 0 { append(&parts, t.distinguishing_features) }
result := strings.join(parts[:], ", ")
defer delete(parts)
return result
}
-1
View File
@@ -46,7 +46,6 @@ dispose_script_owned :: proc(script: ^Comic_Script) {
for d in pan.dialogue {
delete(d.speaker_id)
delete(d.text)
delete(d.emotion)
}
delete(pan.dialogue)
for s in pan.sound_effects { delete(s) }
+95
View File
@@ -0,0 +1,95 @@
package core
import "core:strings"
QUALITY_MODIFIER :: "high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
NEGATIVE_PROMPT_CHARACTER :: "blurry, low quality, distorted face, extra limbs, bad anatomy, deformed, watermark, signature"
NEGATIVE_PROMPT_PANEL :: "blurry, low quality, distorted face, extra limbs, bad anatomy, deformed, watermark, signature, text, speech bubble"
ART_STYLE_KEYWORDS :: [8]string{
"Japanese manga style, black and white ink, screentone shading, speed lines, dramatic shadows, clean linework, right-to-left reading",
"American comic book style, bold outlines, vibrant colors, dynamic poses, cross-hatching, Ben-Day dots, left-to-right reading",
"16-bit pixel art style, retro game aesthetic, limited color palette, crisp pixels, dithering, nostalgic",
"watercolor painting style, soft edges, paper texture, translucent washes, delicate linework, artistic",
"film noir style, high contrast black and white, dramatic chiaroscuro lighting, gritty, shadow-heavy, 1940s aesthetic",
"chibi anime style, super deformed characters, big heads, small bodies, cute, colorful, expressive",
"pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome",
"cyberpunk aesthetic, neon lighting, futuristic cityscapes, holographic elements, high contrast, synthwave colors",
}
get_style_keywords :: proc(key: Art_Style_Key) -> string {
keywords := ART_STYLE_KEYWORDS
return keywords[int(key)]
}
parse_art_style_key :: proc(raw: string) -> Art_Style_Key {
lower := strings.to_lower(raw)
if strings.contains(lower, "manga") { return .Manga }
if strings.contains(lower, "western") || strings.contains(lower, "american") || strings.contains(lower, "comic") { return .Western_Comic }
if strings.contains(lower, "pixel") || strings.contains(lower, "retro") || strings.contains(lower, "8-bit") || strings.contains(lower, "16-bit") { return .Pixel_Art }
if strings.contains(lower, "watercolor") || strings.contains(lower, "watercolour") { return .Watercolor }
if strings.contains(lower, "noir") || strings.contains(lower, "film noir") { return .Noir }
if strings.contains(lower, "chibi") || strings.contains(lower, "super deformed") { return .Chibi }
if strings.contains(lower, "sketch") || strings.contains(lower, "pencil") || strings.contains(lower, "draw") { return .Sketch }
if strings.contains(lower, "cyberpunk") || strings.contains(lower, "neon") || strings.contains(lower, "synthwave") { return .Cyberpunk }
return .Western_Comic
}
Sdxl_Image_Size :: enum {
Square,
Landscape_16_9,
Landscape_4_3,
Portrait_4_3,
Square_HD,
Portrait_16_9,
}
sdxl_size_name :: proc(s: Sdxl_Image_Size) -> string {
switch s {
case .Square: return "square"
case .Landscape_16_9: return "landscape_16_9"
case .Landscape_4_3: return "landscape_4_3"
case .Portrait_4_3: return "portrait_4_3"
case .Square_HD: return "square_hd"
case .Portrait_16_9: return "portrait_16_9"
}
return "landscape_4_3"
}
get_image_size_for_shot_type :: proc(shot: Shot_Type) -> Sdxl_Image_Size {
switch shot {
case .Establishing, .Wide, .Aerial:
return .Landscape_16_9
case .Medium, .Over_Shoulder:
return .Landscape_4_3
case .Close_Up:
return .Portrait_4_3
case .Extreme_Close_Up:
return .Square_HD
}
return .Landscape_4_3
}
emotion_name :: proc(e: Emotion) -> string {
switch e {
case .Happy: return "happy"
case .Sad: return "sad"
case .Angry: return "angry"
case .Surprised: return "surprised"
case .Neutral: return "neutral"
case .Determined: return "determined"
}
return "neutral"
}
parse_emotion :: proc(raw: string) -> Emotion {
lower := strings.to_lower(raw)
if strings.contains(lower, "happy") || strings.contains(lower, "joy") || strings.contains(lower, "smile") { return .Happy }
if strings.contains(lower, "sad") || strings.contains(lower, "cry") || strings.contains(lower, "sorrow") { return .Sad }
if strings.contains(lower, "angry") || strings.contains(lower, "rage") || strings.contains(lower, "furious") { return .Angry }
if strings.contains(lower, "surpris") || strings.contains(lower, "shock") || strings.contains(lower, "amazed") { return .Surprised }
if strings.contains(lower, "determin") || strings.contains(lower, "resolv") || strings.contains(lower, "focused") { return .Determined }
return .Neutral
}
+24
View File
@@ -54,3 +54,27 @@ normalize_script :: proc(script: Comic_Script) -> Comic_Script {
return normalized
}
count_character_appearances :: proc(script: ^Comic_Script) {
for i in 0..<len(script.characters) {
char_id := script.characters[i].id
count := 0
first_panel := ""
for page_idx in 0..<len(script.pages) {
for panel_idx in 0..<len(script.pages[page_idx].panels) {
panel := &script.pages[page_idx].panels[panel_idx]
for cid in panel.characters_present {
if cid == char_id {
count += 1
if len(first_panel) == 0 {
first_panel = panel.panel_id
}
break
}
}
}
}
script.characters[i].appearance_count = count
script.characters[i].first_appearance_panel = first_panel
}
}
+21 -1
View File
@@ -70,6 +70,15 @@ Bubble_Type :: enum {
Sound_Effect,
}
Emotion :: enum {
Happy,
Sad,
Angry,
Surprised,
Neutral,
Determined,
}
Transition_Type :: enum {
None,
Fade,
@@ -78,6 +87,17 @@ Transition_Type :: enum {
Action_Lines,
}
Art_Style_Key :: enum {
Manga,
Western_Comic,
Pixel_Art,
Watercolor,
Noir,
Chibi,
Sketch,
Cyberpunk,
}
Position :: struct {
x: f32,
y: f32,
@@ -126,7 +146,7 @@ Dialogue :: struct {
speaker_id: string,
text: string,
bubble_type: Bubble_Type,
emotion: string,
emotion: Emotion,
}
Panel :: struct {
+285
View File
@@ -476,3 +476,288 @@ autosave_tick_with_message :: proc(project_path: ^string, state: core.Comic_Stat
}
return fmt.aprintf("Autosave failed: %s", err.message)
}
action_add_bubble :: proc(controller: ^ui.App_Controller, panel_id: string) -> string {
id := fmt.aprintf("bubble_%s_new", panel_id)
bubble := core.Speech_Bubble{
id = id,
panel_id = panel_id,
type = .Normal,
text = "New bubble",
position = core.Position{x = 0.1, y = 0.1},
size = core.Size{width = 120, height = 40},
tail_direction = "bottom",
tail_target = core.Position{x = 0.5, y = 0.5},
style = core.DEFAULT_BUBBLE_STYLE,
speaker_id = "",
}
if controller.state.speech_bubbles == nil {
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
}
existing: [dynamic]core.Speech_Bubble
if slice, ok := controller.state.speech_bubbles[panel_id]; ok {
for b in slice {
append(&existing, b)
}
}
append(&existing, bubble)
controller.state.speech_bubbles[panel_id] = existing[:]
return "Added bubble"
}
action_delete_bubble :: proc(controller: ^ui.App_Controller, panel_id: string, bubble_idx: int) -> string {
if controller.state.speech_bubbles == nil {
return "No bubbles to delete"
}
slice, ok := controller.state.speech_bubbles[panel_id]
if !ok || bubble_idx < 0 || bubble_idx >= len(slice) {
return "Bubble not found"
}
// Remove by shifting elements
new_slice: [dynamic]core.Speech_Bubble
for i in 0..<len(slice) {
if i != bubble_idx {
append(&new_slice, slice[i])
}
}
if len(new_slice) == 0 {
delete_key(&controller.state.speech_bubbles, panel_id)
} else {
controller.state.speech_bubbles[panel_id] = new_slice[:]
}
return "Deleted bubble"
}
action_auto_place_bubbles_for_panel :: proc(controller: ^ui.App_Controller, panel_id: string, layout: core.Page_Layout) -> string {
// Find the script panel to get dialogue
script_panel: core.Panel
found := false
for page in controller.state.script.pages {
for p in page.panels {
if p.panel_id == panel_id {
script_panel = p
found = true
break
}
}
if found { break }
}
if !found {
return "Script panel not found for auto-place"
}
// Find the layout cell for this panel to get dimensions
cell_w: f32 = 1
cell_h: f32 = 1
for lp in layout.panels {
if lp.panel_id == panel_id {
cell_w = lp.layout_cell.w
cell_h = lp.layout_cell.h
break
}
}
panel_w := cell_w * f32(layout.width)
panel_h := cell_h * f32(layout.height)
bubbles := core.auto_place_panel_bubbles(script_panel, panel_w, panel_h)
defer delete(bubbles)
if len(bubbles) == 0 {
return "No dialogue to auto-place"
}
if controller.state.speech_bubbles == nil {
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
}
controller.state.speech_bubbles[panel_id] = bubbles
return fmt.tprintf("Auto-placed %d bubbles", len(bubbles))
}
action_update_bubble :: proc(controller: ^ui.App_Controller, panel_id: string, bubble_idx: int, new_type: core.Bubble_Type, new_text: string) -> string {
if controller.state.speech_bubbles == nil {
return "No bubbles to update"
}
slice, ok := controller.state.speech_bubbles[panel_id]
if !ok || bubble_idx < 0 || bubble_idx >= len(slice) {
return "Bubble not found"
}
// Copy to dynamic slice, update, then reassign
new_slice: [dynamic]core.Speech_Bubble
for i in 0..<len(slice) {
b := slice[i]
if i == bubble_idx {
if new_text != "" {
b.text = new_text
}
b.type = new_type
}
append(&new_slice, b)
}
controller.state.speech_bubbles[panel_id] = new_slice[:]
return "Bubble updated"
}
action_generate_character_reference :: proc(controller: ^ui.App_Controller, character_id: string) -> string {
cfg := shared.load_config()
if len(cfg.fal_api_key) == 0 {
return "FAL_API_KEY is missing"
}
// Find the character
target_char: core.Character
found := false
for c in controller.state.characters {
if c.id == character_id {
target_char = c
found = true
break
}
}
if !found {
return "Character not found"
}
url, err := adapters.generate_character_reference_stub(cfg, target_char, controller.state.art_style)
if !shared.is_ok(err) {
return err.message
}
// Update character with reference image
new_chars: [dynamic]core.Character
for c in controller.state.characters {
new_c := c
if c.id == character_id {
new_c.reference_image_url = url
}
append(&new_chars, new_c)
}
delete(controller.state.characters)
controller.state.characters = new_chars[:]
return fmt.tprintf("Generated reference for %s", target_char.name)
}
action_generate_character_sheet :: proc(controller: ^ui.App_Controller, character_id: string) -> string {
cfg := shared.load_config()
if len(cfg.fal_api_key) == 0 {
return "FAL_API_KEY is missing"
}
// Find the character
target_char: core.Character
found := false
for c in controller.state.characters {
if c.id == character_id {
target_char = c
found = true
break
}
}
if !found {
return "Character not found"
}
sheet_urls, err := adapters.generate_character_sheet_stub(cfg, target_char, controller.state.art_style)
if !shared.is_ok(err) {
return err.message
}
// Update character with sheet URLs
new_chars: [dynamic]core.Character
for c in controller.state.characters {
new_c := c
if c.id == character_id {
// Free old sheet URLs
for u in c.character_sheet_urls {
delete(u)
}
delete(c.character_sheet_urls)
new_c.character_sheet_urls = sheet_urls
}
append(&new_chars, new_c)
}
delete(controller.state.characters)
controller.state.characters = new_chars[:]
return fmt.tprintf("Generated %d-pose sheet for %s", len(sheet_urls), target_char.name)
}
action_update_bubble_text :: proc(controller: ^ui.App_Controller, bubble_id: string, new_text: string) -> string {
if controller.state.speech_bubbles == nil {
return "No bubbles to update"
}
for panel_id, slice in controller.state.speech_bubbles {
for i in 0..<len(slice) {
if slice[i].id == bubble_id {
new_slice: [dynamic]core.Speech_Bubble
for j in 0..<len(slice) {
b := slice[j]
if j == i {
b.text = new_text
}
append(&new_slice, b)
}
controller.state.speech_bubbles[panel_id] = new_slice[:]
return "Bubble text updated"
}
}
}
return "Bubble not found"
}
action_reposition_bubble :: proc(controller: ^ui.App_Controller, bubble_id: string, x, y: f32) -> string {
if controller.state.speech_bubbles == nil {
return "No bubbles to reposition"
}
for panel_id, slice in controller.state.speech_bubbles {
for i in 0..<len(slice) {
if slice[i].id == bubble_id {
new_slice: [dynamic]core.Speech_Bubble
for j in 0..<len(slice) {
b := slice[j]
if j == i {
b.position = core.Position{x = x, y = y}
}
append(&new_slice, b)
}
controller.state.speech_bubbles[panel_id] = new_slice[:]
return "Bubble repositioned"
}
}
}
return "Bubble not found"
}
action_reset_bubble_position :: proc(controller: ^ui.App_Controller, bubble_id: string) -> string {
if controller.state.speech_bubbles == nil {
return "No bubbles to reset"
}
for panel_id, slice in controller.state.speech_bubbles {
for i in 0..<len(slice) {
if slice[i].id == bubble_id {
new_slice: [dynamic]core.Speech_Bubble
for j in 0..<len(slice) {
b := slice[j]
if j == i {
b.position = core.Position{x = 0, y = 0}
}
append(&new_slice, b)
}
controller.state.speech_bubbles[panel_id] = new_slice[:]
return "Bubble position reset"
}
}
}
return "Bubble not found"
}
+255
View File
@@ -0,0 +1,255 @@
package gui
import "core:fmt"
import rl "vendor:raylib"
import "../core"
import "../ui"
bubble_type_name :: proc(t: core.Bubble_Type) -> string {
switch t {
case .Normal: return "Normal"
case .Thought: return "Thought"
case .Shout: return "Shout"
case .Whisper: return "Whisper"
case .Narration: return "Narration"
case .Sound_Effect: return "SFX"
}
return "Normal"
}
bubble_type_from_name :: proc(name: string) -> core.Bubble_Type {
switch name {
case "Normal": return .Normal
case "Thought": return .Thought
case "Shout": return .Shout
case "Whisper": return .Whisper
case "Narration": return .Narration
case "SFX": return .Sound_Effect
}
return .Normal
}
clamp_bubble_cursor :: proc(count, cursor: int) -> int {
if count <= 0 {
return 0
}
if cursor < 0 {
return 0
}
if cursor >= count {
return count - 1
}
return cursor
}
collect_layout_panels_for_page :: proc(layouts: []core.Page_Layout, page_cursor: int) -> []core.Page_Layout_Panel {
if len(layouts) == 0 || page_cursor < 0 || page_cursor >= len(layouts) {
return nil
}
return layouts[page_cursor].panels
}
count_bubbles_for_panel :: proc(bubbles: map[string][]core.Speech_Bubble, panel_id: string) -> int {
if bubbles == nil {
return 0
}
if slice, ok := bubbles[panel_id]; ok {
return len(slice)
}
return 0
}
draw_bubbles_detail_panel :: proc(
controller: ui.App_Controller,
x, y, w, h: i32,
page_cursor: int,
panel_cursor: int,
bubble_cursor: int,
) -> (
add_clicked: bool,
delete_clicked: bool,
auto_place_clicked: bool,
new_page_cursor: int,
new_panel_cursor: int,
new_bubble_cursor: int,
edited_text: string,
edited_type: core.Bubble_Type,
type_changed: bool,
text_changed: bool,
) {
add_clicked = false
delete_clicked = false
auto_place_clicked = false
new_page_cursor = page_cursor
new_panel_cursor = panel_cursor
new_bubble_cursor = bubble_cursor
type_changed = false
text_changed = false
edited_text = ""
edited_type = .Normal
draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)})
draw_section_title(x+18, y+6, "Bubble Editor")
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34})
layout_count := len(controller.state.page_layouts)
if layout_count == 0 {
draw_summary_line(x+18, y+46, "No layouts yet. Run Layout Auto first.", SUMMARY_HINT)
return
}
page_idx := clamp_layout_cursor(layout_count, page_cursor)
if page_idx != page_cursor {
new_page_cursor = page_idx
}
layout := controller.state.page_layouts[page_idx]
panel_list := layout.panels
panel_count := len(panel_list)
if panel_count == 0 {
draw_summary_line(x+18, y+46, fmt.tprintf("Page %d has no panels", layout.page_number), SUMMARY_HINT)
return
}
// Page navigation
draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d • %d panels", page_idx+1, layout_count, panel_count), SUMMARY_ACCENT)
panel_idx := clamp_bubble_cursor(panel_count, panel_cursor)
if panel_idx != panel_cursor {
new_panel_cursor = panel_idx
}
panel := panel_list[panel_idx]
// Panel info + auto-place button
draw_summary_subline(x+18, y+66, fmt.tprintf("Panel %d (id: %s)", panel.panel_number, panel.panel_id), SUMMARY_SUBLINE)
auto_btn_rec := rl.Rectangle{x = f32(x + w - 110), y = f32(y+62), width = 90, height = 22}
draw_small_button_state(auto_btn_rec, "Auto Place", true)
if button_clicked(auto_btn_rec) {
auto_place_clicked = true
}
// Bubble list
bubble_map := controller.state.speech_bubbles
bubbles_for_panel: []core.Speech_Bubble = nil
if bubble_map != nil {
if slice, ok := bubble_map[panel.panel_id]; ok {
bubbles_for_panel = slice
}
}
bubble_count := len(bubbles_for_panel)
bubble_idx := clamp_bubble_cursor(bubble_count, bubble_cursor)
if bubble_count > 0 && bubble_idx != bubble_cursor {
new_bubble_cursor = bubble_idx
}
// Bubble list header
list_y := y + 90
draw_summary_line(x+18, list_y, fmt.tprintf("Bubbles: %d", bubble_count), SUMMARY_ACCENT)
// Add button
add_btn_rec := rl.Rectangle{x = f32(x + w - 70), y = f32(list_y-4), width = 50, height = 20}
draw_small_button_state(add_btn_rec, "Add", true)
if button_clicked(add_btn_rec) {
add_clicked = true
}
// Bubble rows
row_start_y := list_y + 22
row_h: i32 = 20
max_rows: int = int(h - 120) / int(row_h)
if max_rows < 1 {
max_rows = 1
}
// Scroll window for bubble list
scroll_start := 0
if bubble_idx >= max_rows {
scroll_start = bubble_idx - max_rows + 1
}
scroll_end := scroll_start + max_rows
if scroll_end > bubble_count {
scroll_end = bubble_count
scroll_start = scroll_end - max_rows
if scroll_start < 0 {
scroll_start = 0
}
}
row: i32 = 0
for i in scroll_start..<scroll_end {
b := bubbles_for_panel[i]
mark := " "
row_color := TEXT_TERTIARY
if i == bubble_idx {
mark = ">"
row_color = TEXT_PRIMARY
}
row_rec := rl.Rectangle{x = f32(x+18), y = f32(row_start_y+row*row_h), width = f32(w-90), height = f32(row_h)}
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && rl.IsMouseButtonPressed(.LEFT) {
new_bubble_cursor = i
}
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && row_color == TEXT_TERTIARY {
row_color = TEXT_SECONDARY
}
// Delete button on hover/selected
del_rec := rl.Rectangle{x = f32(x+w-28), y = f32(row_start_y+row*row_h), width = 18, height = f32(row_h)}
if rl.CheckCollisionPointRec(rl.GetMousePosition(), del_rec) && rl.IsMouseButtonPressed(.LEFT) && i == bubble_idx {
delete_clicked = true
}
if i == bubble_idx {
draw_text_fitted("x", x+w-26, row_start_y+row*row_h+3, 12, 14, 7, ERROR)
}
preview := b.text
if len(preview) > 30 {
preview = preview[:30]
preview = fmt.tprintf("%s...", preview)
}
if len(preview) == 0 {
preview = "(empty)"
}
draw_summary_subline(x+18, row_start_y+row*row_h+2,
fit_text_for_width(fmt.tprintf("%s [%s] %s", mark, bubble_type_name(b.type), preview), int(w-100), 7), row_color)
row += 1
}
// Selected bubble editor
if bubble_count > 0 && bubble_idx < bubble_count {
editor_y := row_start_y + row*row_h + 8
if editor_y < y+h-80 {
selected := bubbles_for_panel[bubble_idx]
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(editor_y-6), width = f32(w-24), height = 70})
draw_summary_line(x+18, editor_y, fmt.tprintf("Editing: %s", bubble_type_name(selected.type)), SUMMARY_ACCENT)
// Type selector buttons
type_y := editor_y + 20
type_idx: i32 = 0
types := []core.Bubble_Type{.Normal, .Thought, .Shout, .Whisper, .Narration, .Sound_Effect}
type_w: i32 = 60
type_spacing: i32 = 4
for t in types {
t_rec := rl.Rectangle{x = f32(x)+18+f32(int(type_idx)*int(type_w+type_spacing)), y = f32(type_y), width = f32(type_w), height = 18}
draw_nav_item(t_rec, bubble_type_name(t), selected.type == t)
if button_clicked(t_rec) && selected.type != t {
edited_type = t
type_changed = true
}
type_idx += 1
}
// Text preview
text_y := type_y + 22
draw_summary_subline(x+18, text_y, fit_text_for_width(fmt.tprintf("text: %s", selected.text), int(w-36), 7), SUMMARY_SUBLINE)
}
}
if bubble_count == 0 {
draw_summary_line(x+18, row_start_y, "No bubbles for this panel. Click Add or Auto Place.", SUMMARY_HINT)
}
return
}
+21 -5
View File
@@ -41,7 +41,7 @@ build_local_script :: proc(story_idea: string, pages: int) -> core.Comic_Script
chars_present := make([]string, 1)
chars_present[0] = "char_001"
dialogue := make([]core.Dialogue, 1)
dialogue[0] = core.Dialogue{speaker_id = "char_001", text = "Let's do this.", bubble_type = .Normal, emotion = "neutral"}
dialogue[0] = core.Dialogue{speaker_id = "char_001", text = "Let's do this.", bubble_type = .Normal, emotion = .Neutral}
panel := core.Panel{
panel_id = local_panel_id_by_index(i),
@@ -75,13 +75,29 @@ build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel
name := fmt.aprintf("panel_%03d_%s.png", idx+1, p.panel_id)
out_path := fmt.aprintf("%s/%s", tmp_dir, name)
delete(name)
if werr := os.write_entire_file(out_path, "LOCAL PANEL IMAGE"); werr != nil {
// Create a real PNG image using python3
gen_w := 1024
gen_h := 1024
py_script := fmt.aprintf(
"import struct,zlib,sys;w,h=%d,%d;rows=[]\nfor _ in range(h): rows.append(b'\\x00'+b'\\xff\\xff\\xff'*w)\nraw=b''.join(rows);comp=zlib.compress(raw)\ndef crc(d): return struct.pack('>I',zlib.crc32(d)&0xffffffff)\nf=open(sys.argv[1],'wb')\nf.write(b'\\x89PNG\\r\\n\\x1a\\n')\nihdr_data=struct.pack('>IIBBBBB',w,h,8,2,0,0,0)\nf.write(struct.pack('>I',13)+b'IHDR'+ihdr_data+crc(b'IHDR'+ihdr_data))\nf.write(struct.pack('>I',len(comp))+b'IDAT'+comp+crc(b'IDAT'+comp))\nf.write(struct.pack('>I',0)+b'IEND'+crc(b'IEND'))\nf.close()",
gen_w, gen_h,
)
defer delete(py_script)
py_cmd := [4]string{"python3", "-c", py_script, out_path}
desc := os.Process_Desc{command = py_cmd[:]}
state, _, stderr, cerr := os.process_exec(desc, context.temp_allocator)
if cerr != nil || !state.exited || state.exit_code != 0 {
delete(out_path)
return nil, shared.new_error(.Generation, "failed writing local panel image", true)
msg := fmt.aprintf("failed to create panel image: %s", string(stderr))
defer delete(msg)
return nil, shared.new_error(.Generation, msg, true)
}
url := fmt.aprintf("file://%s", out_path)
prompt := fmt.aprintf("local")
images[p.panel_id] = core.Panel_Image{url = url, width = 1024, height = 1024, seed = i64(idx + 1), prompt = prompt}
prompt := fmt.aprintf("local panel %d", idx+1)
images[p.panel_id] = core.Panel_Image{url = url, width = gen_w, height = gen_h, seed = i64(idx + 1), prompt = prompt}
delete(out_path)
}
return images, shared.ok()
+173 -24
View File
@@ -49,19 +49,13 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
for !rl.WindowShouldClose() {
screen_w_loop := rl.GetScreenWidth()
screen_h_loop := rl.GetScreenHeight()
compact_mode := screen_h_loop < 860
compact_mode := shared.is_compact(screen_h_loop)
cfg_loop := shared.load_config()
has_deepseek_key := len(cfg_loop.deepseek_api_key) > 0
main_w_loop := screen_w_loop - 282 - 20
if main_w_loop < 960 {
main_w_loop = 960
}
main_w_loop := shared.compute_main_width(screen_w_loop)
status_w_loop := (main_w_loop - 2) / 2
log_x_loop := 282 + status_w_loop + 2
lower_y_loop := screen_h_loop - 252
if lower_y_loop < 450 {
lower_y_loop = 450
}
log_x_loop := shared.LAYOUT.sidebar_width + status_w_loop + 2
lower_y_loop := shared.compute_lower_y(screen_h_loop)
idea_rec := rl.Rectangle{x = 420, y = 90, width = f32(main_w_loop-460), height = 36}
genre_rec := rl.Rectangle{x = 420, y = 146, width = f32(main_w_loop-460), height = 36}
@@ -125,8 +119,8 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
path_fix_export_status_btn := rl.Rectangle{x = 674, y = 556, width = 32, height = 20}
path_fix_all_status_btn := rl.Rectangle{x = 710, y = 556, width = 34, height = 20}
summary_show_btn := rl.Rectangle{x = f32(282 + status_w_loop - 178), y = f32(lower_y_loop + 18), width = 78, height = 24}
summary_sort_btn := rl.Rectangle{x = f32(282 + status_w_loop - 94), y = f32(lower_y_loop + 18), width = 78, height = 24}
summary_show_btn := rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width + status_w_loop - 178), y = f32(lower_y_loop + 18), width = 78, height = 24}
summary_sort_btn := rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width + status_w_loop - 94), y = f32(lower_y_loop + 18), width = 78, height = 24}
summary_prev_btn := rl.Rectangle{x = f32(300), y = f32(lower_y_loop + 18), width = 52, height = 24}
summary_next_btn := rl.Rectangle{x = f32(358), y = f32(lower_y_loop + 18), width = 52, height = 24}
@@ -351,6 +345,36 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count))
}
}
if controller.active_screen == .Bubbles && button_clicked(summary_prev_btn) {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
panel_count := len(controller.state.page_layouts[page_idx].panels)
if panel_count > 0 {
summary_opts.bubble_panel_cursor -= 1
if summary_opts.bubble_panel_cursor < 0 {
summary_opts.bubble_panel_cursor = panel_count - 1
}
summary_opts.bubble_edit_cursor = 0
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_panel_cursor+1, panel_count))
}
}
}
if controller.active_screen == .Bubbles && button_clicked(summary_next_btn) {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
panel_count := len(controller.state.page_layouts[page_idx].panels)
if panel_count > 0 {
summary_opts.bubble_panel_cursor += 1
if summary_opts.bubble_panel_cursor >= panel_count {
summary_opts.bubble_panel_cursor = 0
}
summary_opts.bubble_edit_cursor = 0
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_panel_cursor+1, panel_count))
}
}
}
if button_clicked(new_btn) {
if is_dirty && !shift_down {
@@ -559,6 +583,36 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count))
}
}
if controller.active_screen == .Bubbles && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
panel_count := len(controller.state.page_layouts[page_idx].panels)
if panel_count > 0 {
summary_opts.bubble_panel_cursor -= 1
if summary_opts.bubble_panel_cursor < 0 {
summary_opts.bubble_panel_cursor = panel_count - 1
}
summary_opts.bubble_edit_cursor = 0
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_panel_cursor+1, panel_count))
}
}
}
if controller.active_screen == .Bubbles && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
panel_count := len(controller.state.page_layouts[page_idx].panels)
if panel_count > 0 {
summary_opts.bubble_panel_cursor += 1
if summary_opts.bubble_panel_cursor >= panel_count {
summary_opts.bubble_panel_cursor = 0
}
summary_opts.bubble_edit_cursor = 0
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_panel_cursor+1, panel_count))
}
}
}
if ctrl_down && rl.IsKeyPressed(.MINUS) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs-5))
}
@@ -690,10 +744,7 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
rl.ClearBackground(BG_BASE)
screen_w := rl.GetScreenWidth()
screen_h := rl.GetScreenHeight()
main_w := screen_w - 282 - 20
if main_w < 960 {
main_w = 960
}
main_w := shared.compute_main_width(screen_w)
rl.DrawRectangle(0, 0, 260, screen_h, BG_SIDEBAR)
rl.DrawRectangle(260, 0, screen_w-260, 72, BG_TOPBAR)
@@ -716,7 +767,7 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
draw_sidebar_shortcuts(screen_h_loop)
// --- Pipeline Stepper in Topbar ---
draw_card(rl.Rectangle{x = 282, y = 12, width = f32(main_w), height = 48})
draw_card(rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width), y = 12, width = f32(main_w), height = 48})
rl.DrawText(fmt.ctprintf("%s", ui.screen_name(controller.active_screen)), 300, 26, 20, BRAND_TITLE)
script_ok := len(controller.state.script.pages) > 0
@@ -768,8 +819,8 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
rl.DrawText("Local script pages", screen_w_loop - 250, 26, 16, TEXT_SECONDARY)
draw_input_field(pages_rec, local_script_pages, selected_field == 4)
draw_card(rl.Rectangle{x = 282, y = 82, width = f32(main_w), height = 356})
draw_card(rl.Rectangle{x = 282, y = 460, width = f32(main_w), height = 110})
draw_card(rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width), y = 82, width = f32(main_w), height = 356})
draw_card(rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width), y = 460, width = f32(main_w), height = 110})
draw_section_title(300, 92, "Project Setup")
draw_section_title(300, 470, "Actions")
@@ -867,11 +918,11 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
}
}
draw_card(rl.Rectangle{x = 282, y = f32(lower_y_loop-140), width = f32(status_w_loop), height = 160})
draw_card(rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width), y = f32(lower_y_loop-140), width = f32(status_w_loop), height = 160})
now_draw := rl.GetTime()
status_y := lower_y_loop - 126
rl.DrawText("Status", 300, status_y, 19, BRAND_TITLE)
rl.DrawLine(370, status_y+10, i32(282+status_w_loop)-14, status_y+10, BORDER_DIVIDER)
rl.DrawLine(370, status_y+10, i32(shared.LAYOUT.sidebar_width+status_w_loop)-14, status_y+10, BORDER_DIVIDER)
draw_text_fitted(status_msg, 300, status_y+26, 18, int(status_w_loop-36), 8, status_text_color(status_msg))
draw_readiness_row(controller, 300, status_y+50)
ready_count, total_count := ready_stage_count(controller)
@@ -901,7 +952,7 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
draw_small_button_state(path_fix_all_status_btn, "PE", !project_path_ok || !export_path_ok)
draw_screen_summary(controller, export_path, 300, lower_y_loop+12, status_w_loop-8, summary_opts)
if controller.active_screen == .Script || controller.active_screen == .Layout || controller.active_screen == .Panels {
if controller.active_screen == .Script || controller.active_screen == .Layout || controller.active_screen == .Panels || controller.active_screen == .Bubbles {
if controller.active_screen == .Script || controller.active_screen == .Layout {
show_txt := "Top"
sort_txt := "Asc"
@@ -932,10 +983,13 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
} else if controller.active_screen == .Layout {
draw_small_button(summary_prev_btn, "< Ly")
draw_small_button(summary_next_btn, "Ly >")
} else if controller.active_screen == .Bubbles {
draw_small_button(summary_prev_btn, "< Pn")
draw_small_button(summary_next_btn, "Pn >")
}
if !compact_mode {
hint_label := "Ctrl+[ / Ctrl+]"
draw_hint_pill(rl.Rectangle{x = f32(282 + status_w_loop - 186), y = f32(lower_y_loop + 46), width = 172, height = 20}, hint_label, false)
draw_hint_pill(rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width + status_w_loop - 186), y = f32(lower_y_loop + 46), width = 172, height = 20}, hint_label, false)
}
}
if !compact_mode {
@@ -985,6 +1039,7 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
if regen {
msg := action_regenerate_page_layout(&controller, summary_opts.layout_page_cursor)
push_status(&status_msg, &action_log, msg)
is_dirty = true
}
wheel := rl.GetMouseWheelMove()
@@ -999,7 +1054,101 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count))
}
draw_text_fitted("Ctrl+H / Ctrl+J layout nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY)
draw_text_fitted("Ctrl+[ / Ctrl+] layout nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY)
} else if controller.active_screen == .Bubbles {
add_clicked, delete_clicked, auto_place_clicked, new_page_cursor, new_panel_cursor, new_bubble_cursor, edited_text, edited_type, type_changed, text_changed := draw_bubbles_detail_panel(
controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200,
summary_opts.bubble_page_cursor, summary_opts.bubble_panel_cursor, summary_opts.bubble_edit_cursor,
)
if summary_opts.bubble_page_cursor != new_page_cursor {
summary_opts.bubble_page_cursor = new_page_cursor
summary_opts.bubble_panel_cursor = 0
summary_opts.bubble_edit_cursor = 0
}
if summary_opts.bubble_panel_cursor != new_panel_cursor {
summary_opts.bubble_panel_cursor = new_panel_cursor
summary_opts.bubble_edit_cursor = 0
}
if summary_opts.bubble_edit_cursor != new_bubble_cursor {
summary_opts.bubble_edit_cursor = new_bubble_cursor
}
if add_clicked {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
layout := controller.state.page_layouts[page_idx]
if len(layout.panels) > 0 {
panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor)
panel_id := layout.panels[panel_idx].panel_id
msg := action_add_bubble(&controller, panel_id)
push_status(&status_msg, &action_log, msg)
is_dirty = true
}
}
}
if delete_clicked {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
layout := controller.state.page_layouts[page_idx]
if len(layout.panels) > 0 {
panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor)
panel_id := layout.panels[panel_idx].panel_id
msg := action_delete_bubble(&controller, panel_id, summary_opts.bubble_edit_cursor)
push_status(&status_msg, &action_log, msg)
is_dirty = true
if summary_opts.bubble_edit_cursor > 0 {
summary_opts.bubble_edit_cursor -= 1
}
}
}
}
if auto_place_clicked {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
layout := controller.state.page_layouts[page_idx]
if len(layout.panels) > 0 {
panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor)
panel_id := layout.panels[panel_idx].panel_id
msg := action_auto_place_bubbles_for_panel(&controller, panel_id, layout)
push_status(&status_msg, &action_log, msg)
is_dirty = true
}
}
}
if type_changed {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor)
layout := controller.state.page_layouts[page_idx]
if len(layout.panels) > 0 {
panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor)
panel_id := layout.panels[panel_idx].panel_id
msg := action_update_bubble(&controller, panel_id, summary_opts.bubble_edit_cursor, edited_type, "")
push_status(&status_msg, &action_log, msg)
is_dirty = true
}
}
}
wheel := rl.GetMouseWheelMove()
if wheel != 0 {
layout_page_count := len(controller.state.page_layouts)
if layout_page_count > 0 {
summary_opts.bubble_panel_cursor -= int(wheel)
if summary_opts.bubble_panel_cursor < 0 {
summary_opts.bubble_panel_cursor = 0
}
panel_count := len(controller.state.page_layouts[summary_opts.bubble_page_cursor].panels)
if summary_opts.bubble_panel_cursor >= panel_count {
summary_opts.bubble_panel_cursor = panel_count - 1
}
summary_opts.bubble_edit_cursor = 0
}
}
draw_text_fitted("Ctrl+[ / Ctrl+] panel nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY)
} else {
draw_card(rl.Rectangle{x = f32(log_x_loop), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-2), height = 200})
draw_section_title(log_x_loop+18, lower_y_loop+6, "Action Log")
+85 -6
View File
@@ -176,8 +176,22 @@ draw_screen_summary :: proc(controller: ui.App_Controller, export_path: string,
}
}
case .Bubbles:
draw_summary_line(x, y+30, fmt.tprintf("Bubble maps: %d", len(controller.state.speech_bubbles)), rl.DARKGRAY)
rl.DrawText("Bubble editor is scaffolded", x, y+54, 18, rl.DARKGRAY)
bubble_count := 0
if controller.state.speech_bubbles != nil {
for _, bubbles in controller.state.speech_bubbles {
bubble_count += len(bubbles)
}
}
draw_summary_line(x, y+30, fmt.tprintf("Total bubbles: %d", bubble_count), rl.DARKGRAY)
draw_summary_line(x, y+54, fmt.tprintf("Layout pages: %d", len(controller.state.page_layouts)), rl.DARKGRAY)
if len(controller.state.page_layouts) == 0 {
rl.DrawText("No layouts yet. Run Layout Auto first.", x, y+86, 18, SUMMARY_HINT)
} else if bubble_count == 0 {
rl.DrawText("No bubbles yet. Use Auto Place or Add on Bubbles screen.", x, y+86, 18, SUMMARY_HINT)
} else {
draw_summary_line(x, y+78, fmt.tprintf("Panels with bubbles: %d", len(controller.state.speech_bubbles)), SUMMARY_ACCENT)
rl.DrawText("Select a panel in Bubbles screen to edit.", x, y+102, 18, SUMMARY_HINT)
}
case .Export:
draw_summary_line(x, y+30, fmt.tprintf("Format: %v", controller.state.export_format), rl.DARKGRAY)
draw_summary_line(x, y+54, fmt.tprintf("Layouts: %d | Panels: %d", len(controller.state.page_layouts), len(controller.state.panel_images)), rl.DARKGRAY)
@@ -455,6 +469,62 @@ clamp_layout_cursor :: proc(layout_count, cursor: int) -> int {
return cursor
}
Layout_Validation_Result :: struct {
coverage_pct: f32,
missing_bindings: int,
bounds_violations: int,
total_panels: int,
}
validate_layout_page :: proc(layout: core.Page_Layout, panel_images: map[string]core.Panel_Image) -> Layout_Validation_Result {
result: Layout_Validation_Result
result.total_panels = len(layout.panels)
// Coverage: sum of panel cell areas vs page area
page_area := f32(layout.width) * f32(layout.height)
covered_area: f32 = 0
for p in layout.panels {
cell_w := p.layout_cell.w * f32(layout.width)
cell_h := p.layout_cell.h * f32(layout.height)
covered_area += cell_w * cell_h
}
if page_area > 0 {
result.coverage_pct = covered_area / page_area * 100
}
// Missing bindings: panels without corresponding images
for p in layout.panels {
if _, has := panel_images[p.panel_id]; !has {
result.missing_bindings += 1
}
}
// Bounds violations: cells that extend outside [0,1] range
for p in layout.panels {
c := p.layout_cell
if c.x < 0 || c.y < 0 || c.x+c.w > 1.001 || c.y+c.h > 1.001 {
result.bounds_violations += 1
}
}
return result
}
draw_validation_badge :: proc(x, y, w: i32, label: string, ok: bool) {
bg := UNREADY_BG
border := UNREADY_BORDER
fg := WARNING
if ok {
bg = READY_BG
border = READY_BORDER
fg = SUCCESS
}
rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 22}
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 6, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 6, 1.0, border)
draw_text_fitted(label, x+8, y+5, 11, int(w-16), 6, fg)
}
draw_layout_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) -> (regen_clicked: bool, new_cursor: int) {
new_cursor = cursor
regen_clicked = false
@@ -473,6 +543,15 @@ draw_layout_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32,
// Header line with status
draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d • pattern: %s • %d panels", idx+1, layout_count, layout.pattern_id, len(layout.panels)), SUMMARY_ACCENT)
// Validation badges
val := validate_layout_page(layout, controller.state.panel_images)
coverage_ok := val.coverage_pct >= 80 && val.coverage_pct <= 105
bindings_ok := val.missing_bindings == 0
bounds_ok := val.bounds_violations == 0
draw_validation_badge(x+18, y+68, 100, fmt.tprintf("Cov: %.0f%%", val.coverage_pct), coverage_ok)
draw_validation_badge(x+124, y+68, 110, fmt.tprintf("Bind: %d miss", val.missing_bindings), bindings_ok)
draw_validation_badge(x+240, y+68, 100, fmt.tprintf("Bounds: %d", val.bounds_violations), bounds_ok)
// Regenerate button
btn_rec := rl.Rectangle{x = f32(x + w - 100), y = f32(y+42), width = 80, height = 24}
draw_small_button_state(btn_rec, "Regen", true)
@@ -481,11 +560,11 @@ draw_layout_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32,
}
// Layout dimensions
draw_summary_subline(x+18, y+68, fmt.tprintf("size: %d x %d", layout.width, layout.height), SUMMARY_SUBLINE)
draw_summary_subline(x+18, y+94, fmt.tprintf("size: %d x %d", layout.width, layout.height), SUMMARY_SUBLINE)
// ── Mini wireframe preview ─────────────────────────────────
preview_x := x + 18
preview_y := y + 88
preview_y := y + 114
preview_max_w: f32 = f32(w) * 0.4
preview_max_h: f32 = f32(h - 100)
if preview_max_h < 40 {
@@ -533,10 +612,10 @@ draw_layout_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32,
// ── Page list (right side) ─────────────────────────────────
list_x := x + i32(preview_max_w) + 36
list_y := y + 88
list_y := y + 114
list_w := w - i32(preview_max_w) - 54
row_h: i32 = 18
rows: i32 = (h - 100) / row_h
rows: i32 = (h - 126) / row_h
if rows < 1 {
rows = 1
}
+3
View File
@@ -8,6 +8,9 @@ Summary_View_Options :: struct {
layout_show_all: bool,
layout_desc: bool,
layout_page_cursor: int,
bubble_page_cursor: int,
bubble_panel_cursor: int,
bubble_edit_cursor: int,
}
Pending_Confirm_Action :: enum {
+57
View File
@@ -0,0 +1,57 @@
package shared
Layout_Constants :: struct {
sidebar_width: i32,
right_margin: i32,
min_main_width: i32,
top_reserved_height: i32,
min_lower_y: i32,
compact_height: i32,
wide_width: i32,
}
LAYOUT :: Layout_Constants{
sidebar_width = 282,
right_margin = 20,
min_main_width = 960,
top_reserved_height = 252,
min_lower_y = 450,
compact_height = 860,
wide_width = 1920,
}
Screen_Profile :: enum {
Compact,
Standard,
Wide,
}
screen_profile :: proc(screen_w, screen_h: i32) -> Screen_Profile {
if screen_h < LAYOUT.compact_height {
return .Compact
}
if screen_w >= LAYOUT.wide_width {
return .Wide
}
return .Standard
}
compute_main_width :: proc(screen_w: i32) -> i32 {
w := screen_w - LAYOUT.sidebar_width - LAYOUT.right_margin
if w < LAYOUT.min_main_width {
return LAYOUT.min_main_width
}
return w
}
compute_lower_y :: proc(screen_h: i32) -> i32 {
y := screen_h - LAYOUT.top_reserved_height
if y < LAYOUT.min_lower_y {
return LAYOUT.min_lower_y
}
return y
}
is_compact :: proc(screen_h: i32) -> bool {
return screen_h < LAYOUT.compact_height
}
+10
View File
@@ -22,6 +22,7 @@ Background_Job :: struct {
type: Job_Type,
status: Job_Status,
message: string,
progress: f32,
cancel_requested: bool,
}
@@ -103,3 +104,12 @@ active_jobs_count :: proc(m: Job_Manager) -> int {
}
return count
}
update_job_progress :: proc(m: ^Job_Manager, id: int, progress: f32) -> shared.App_Error {
idx := job_index_by_id(m, id)
if idx < 0 {
return shared.new_error(.Generation, "job not found", true)
}
m.jobs[idx].progress = progress
return shared.ok()
}
+2 -2
View File
@@ -9,9 +9,9 @@ can_open_screen :: proc(state: core.Comic_State, target: App_Screen) -> bool {
case .Script:
return len(state.script.pages) > 0
case .Characters:
return len(state.script.characters) > 0
return len(state.script.pages) > 0
case .Panels:
return len(state.script.pages) > 0 && len(state.characters) > 0
return len(state.script.pages) > 0
case .Layout:
return len(state.panel_images) > 0
case .Bubbles: