check point
This commit is contained in:
1 parent
33c70e776a
commit
b0f9acdb47
37 files changed
+4469
-177
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user