jbilcke-hf HF staff commited on
Commit
421fbba
1 Parent(s): 53fde26

add a new experimental feature

Browse files
package-lock.json CHANGED
@@ -69,6 +69,7 @@
69
  "tailwindcss-animate": "^1.0.6",
70
  "ts-node": "^10.9.1",
71
  "typescript": "^5.4.5",
 
72
  "usehooks-ts": "2.9.1",
73
  "uuid": "^9.0.0",
74
  "zustand": "^4.4.1"
@@ -4320,6 +4321,17 @@
4320
  "node": "^10.12.0 || >=12.0.0"
4321
  }
4322
  },
 
 
 
 
 
 
 
 
 
 
 
4323
  "node_modules/fill-range": {
4324
  "version": "7.0.1",
4325
  "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -9992,6 +10004,20 @@
9992
  }
9993
  }
9994
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9995
  "node_modules/use-sidecar": {
9996
  "version": "1.1.2",
9997
  "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
 
69
  "tailwindcss-animate": "^1.0.6",
70
  "ts-node": "^10.9.1",
71
  "typescript": "^5.4.5",
72
+ "use-file-picker": "^2.1.2",
73
  "usehooks-ts": "2.9.1",
74
  "uuid": "^9.0.0",
75
  "zustand": "^4.4.1"
 
4321
  "node": "^10.12.0 || >=12.0.0"
4322
  }
4323
  },
4324
+ "node_modules/file-selector": {
4325
+ "version": "0.2.4",
4326
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.2.4.tgz",
4327
+ "integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==",
4328
+ "dependencies": {
4329
+ "tslib": "^2.0.3"
4330
+ },
4331
+ "engines": {
4332
+ "node": ">= 10"
4333
+ }
4334
+ },
4335
  "node_modules/fill-range": {
4336
  "version": "7.0.1",
4337
  "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
 
10004
  }
10005
  }
10006
  },
10007
+ "node_modules/use-file-picker": {
10008
+ "version": "2.1.2",
10009
+ "resolved": "https://registry.npmjs.org/use-file-picker/-/use-file-picker-2.1.2.tgz",
10010
+ "integrity": "sha512-ZEIzRi1wXeIXDWr5i55gRBVER8rTkSGskDUY94bciTTAZJHlBnOTRLL/LDYjgz6d+US3yELHnRvtBhLxFGtB0A==",
10011
+ "dependencies": {
10012
+ "file-selector": "0.2.4"
10013
+ },
10014
+ "engines": {
10015
+ "node": ">=12"
10016
+ },
10017
+ "peerDependencies": {
10018
+ "react": ">=16"
10019
+ }
10020
+ },
10021
  "node_modules/use-sidecar": {
10022
  "version": "1.1.2",
10023
  "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
package.json CHANGED
@@ -70,6 +70,7 @@
70
  "tailwindcss-animate": "^1.0.6",
71
  "ts-node": "^10.9.1",
72
  "typescript": "^5.4.5",
 
73
  "usehooks-ts": "2.9.1",
74
  "uuid": "^9.0.0",
75
  "zustand": "^4.4.1"
 
70
  "tailwindcss-animate": "^1.0.6",
71
  "ts-node": "^10.9.1",
72
  "typescript": "^5.4.5",
73
+ "use-file-picker": "^2.1.2",
74
  "usehooks-ts": "2.9.1",
75
  "uuid": "^9.0.0",
76
  "zustand": "^4.4.1"
src/app/interface/about/index.tsx CHANGED
@@ -8,8 +8,8 @@ import { Login } from "../login"
8
  const APP_NAME = `AI Comic Factory`
9
  const APP_DOMAIN = `aicomicfactory.app`
10
  const APP_URL = `https://aicomicfactory.app`
11
- const APP_VERSION = `1.3`
12
- const APP_RELEASE_DATE = `April 2024`
13
 
14
  const ExternalLink = ({ url, children }: { url: string; children: ReactNode }) => {
15
  return (
 
8
  const APP_NAME = `AI Comic Factory`
9
  const APP_DOMAIN = `aicomicfactory.app`
10
  const APP_URL = `https://aicomicfactory.app`
11
+ const APP_VERSION = `1.4`
12
+ const APP_RELEASE_DATE = `May 2024`
13
 
14
  const ExternalLink = ({ url, children }: { url: string; children: ReactNode }) => {
15
  return (
src/app/interface/bottom-bar/bottom-bar.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { startTransition, useEffect, useState } from "react"
 
2
 
3
  import { useStore } from "@/app/store"
4
  import { Button } from "@/components/ui/button"
@@ -14,6 +15,7 @@ import { useLocalStorage } from "usehooks-ts"
14
  import { localStorageKeys } from "../settings-dialog/localStorageKeys"
15
  import { defaultSettings } from "../settings-dialog/defaultSettings"
16
  import { getParam } from "@/lib/getParam"
 
17
 
18
  function BottomBar() {
19
  // deprecated, as HTML-to-bitmap didn't work that well for us
@@ -32,12 +34,15 @@ function BottomBar() {
32
  const allStatus = Object.values(panelGenerationStatus)
33
  const remainingImages = allStatus.reduce((acc, s) => (acc + (s ? 1 : 0)), 0)
34
 
 
 
35
  const upscaleQueue = useStore(s => s.upscaleQueue)
36
  const renderedScenes = useStore(s => s.renderedScenes)
37
  const removeFromUpscaleQueue = useStore(s => s.removeFromUpscaleQueue)
38
  const setRendered = useStore(s => s.setRendered)
39
  const [isUpscaling, setUpscaling] = useState(false)
40
 
 
41
  const downloadClap = useStore(s => s.downloadClap)
42
 
43
  const [hasGeneratedAtLeastOnce, setHasGeneratedAtLeastOnce] = useLocalStorage<boolean>(
@@ -87,6 +92,27 @@ function BottomBar() {
87
  }
88
  }, [hasFinishedGeneratingImages, hasGeneratedAtLeastOnce])
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  return (
91
  <div className={cn(
92
  `print:hidden`,
@@ -152,21 +178,25 @@ function BottomBar() {
152
  </Button>
153
  </div>
154
  */}
 
 
 
 
155
  {canSeeBetaFeatures ? <Button
156
  onClick={downloadClap}
157
- disabled={!prompt?.length || remainingImages > 0}
158
  >
159
- {remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} ⌛` : `Save .clap`}
160
  </Button> : null}
161
  <Button
162
  onClick={handlePrint}
163
  disabled={!prompt?.length}
164
  >
165
  <span className="hidden md:inline">{
166
- remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} panels ⌛` : `Save PDF`
167
  }</span>
168
  <span className="inline md:hidden">{
169
- remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} ⌛` : `Save`
170
  }</span>
171
  </Button>
172
  <Share />
 
1
  import { startTransition, useEffect, useState } from "react"
2
+ import { useFilePicker } from 'use-file-picker'
3
 
4
  import { useStore } from "@/app/store"
5
  import { Button } from "@/components/ui/button"
 
15
  import { localStorageKeys } from "../settings-dialog/localStorageKeys"
16
  import { defaultSettings } from "../settings-dialog/defaultSettings"
17
  import { getParam } from "@/lib/getParam"
18
+ import { Input } from "@/components/ui/input"
19
 
20
  function BottomBar() {
21
  // deprecated, as HTML-to-bitmap didn't work that well for us
 
34
  const allStatus = Object.values(panelGenerationStatus)
35
  const remainingImages = allStatus.reduce((acc, s) => (acc + (s ? 1 : 0)), 0)
36
 
37
+ const currentClap = useStore(s => s.currentClap)
38
+
39
  const upscaleQueue = useStore(s => s.upscaleQueue)
40
  const renderedScenes = useStore(s => s.renderedScenes)
41
  const removeFromUpscaleQueue = useStore(s => s.removeFromUpscaleQueue)
42
  const setRendered = useStore(s => s.setRendered)
43
  const [isUpscaling, setUpscaling] = useState(false)
44
 
45
+ const loadClap = useStore(s => s.loadClap)
46
  const downloadClap = useStore(s => s.downloadClap)
47
 
48
  const [hasGeneratedAtLeastOnce, setHasGeneratedAtLeastOnce] = useLocalStorage<boolean>(
 
92
  }
93
  }, [hasFinishedGeneratingImages, hasGeneratedAtLeastOnce])
94
 
95
+ const { openFilePicker, filesContent } = useFilePicker({
96
+ accept: '.clap',
97
+ readAs: "ArrayBuffer"
98
+ })
99
+ const fileData = filesContent[0]
100
+
101
+ useEffect(() => {
102
+ const fn = async () => {
103
+ if (fileData?.name) {
104
+ try {
105
+ const blob = new Blob([fileData.content])
106
+ await loadClap(blob)
107
+ } catch (err) {
108
+ console.error("failed to load the Clap file:", err)
109
+ }
110
+ }
111
+ }
112
+ fn()
113
+ }, [fileData?.name])
114
+
115
+
116
  return (
117
  <div className={cn(
118
  `print:hidden`,
 
178
  </Button>
179
  </div>
180
  */}
181
+ {canSeeBetaFeatures ? <Button
182
+ onClick={openFilePicker}
183
+ disabled={remainingImages > 0}
184
+ >Load</Button> : null}
185
  {canSeeBetaFeatures ? <Button
186
  onClick={downloadClap}
187
+ disabled={!prompt?.length || remainingImages > 0 || !currentClap}
188
  >
189
+ {remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} ⌛` : `Save`}
190
  </Button> : null}
191
  <Button
192
  onClick={handlePrint}
193
  disabled={!prompt?.length}
194
  >
195
  <span className="hidden md:inline">{
196
+ remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} panels ⌛` : `Get PDF`
197
  }</span>
198
  <span className="inline md:hidden">{
199
+ remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} ⌛` : `PDF`
200
  }</span>
201
  </Button>
202
  <Share />
src/app/interface/panel/index.tsx CHANGED
@@ -286,6 +286,15 @@ export function Panel({
286
  useEffect(() => {
287
  if (!prompt.length) { return }
288
 
 
 
 
 
 
 
 
 
 
289
  startImageGeneration({ prompt, width, height, nbFrames, revision })
290
 
291
  clearTimeout(timeoutRef.current)
@@ -456,7 +465,13 @@ export function Panel({
456
  height={height}
457
  alt={rendered.alt}
458
  className={cn(
459
- `comic-panel w-full h-full object-cover max-w-max`,
 
 
 
 
 
 
460
  // showCaptions ? `-mt-11` : ''
461
  )}
462
  />}
 
286
  useEffect(() => {
287
  if (!prompt.length) { return }
288
 
289
+ const renderedScene: RenderedScene | undefined = useStore.getState().renderedScenes[panelIndex]
290
+
291
+ // I'm trying to find a rule to handle the case were we load a .clap file
292
+ // I think we should trash all the Panel objects for this to work properly
293
+ if (renderedScene && renderedScene.status === "pregenerated" && renderedScene.assetUrl) {
294
+ console.log(`loading a pre-generated panel..`)
295
+ return
296
+ }
297
+
298
  startImageGeneration({ prompt, width, height, nbFrames, revision })
299
 
300
  clearTimeout(timeoutRef.current)
 
465
  height={height}
466
  alt={rendered.alt}
467
  className={cn(
468
+ `comic-panel w-full h-full`,
469
+ `object-cover`,
470
+
471
+ // I think we can remove this to improve compatibility,
472
+ // in case the generate image isn't exactly the same size
473
+ // `max-w-max`,
474
+
475
  // showCaptions ? `-mt-11` : ''
476
  )}
477
  />}
src/app/interface/share/index.tsx CHANGED
@@ -119,10 +119,10 @@ ${comicFileMd}`;
119
  disabled={!prompt?.length}
120
  >
121
  <span className="hidden md:inline">{
122
- remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} panels ⌛` : `Save PDF`
123
  }</span>
124
  <span className="inline md:hidden">{
125
- remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} ⌛` : `Save`
126
  }</span>
127
  </Button>
128
  </p>
 
119
  disabled={!prompt?.length}
120
  >
121
  <span className="hidden md:inline">{
122
+ remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} panels ⌛` : `Get PDF`
123
  }</span>
124
  <span className="inline md:hidden">{
125
+ remainingImages ? `${allStatus.length - remainingImages}/${allStatus.length} ⌛` : `PDF`
126
  }</span>
127
  </Button>
128
  </p>
src/app/interface/top-menu/index.tsx CHANGED
@@ -79,6 +79,7 @@ export function TopMenu() {
79
  requestedStoryPrompt
80
  )
81
 
 
82
  const [draftPromptA, setDraftPromptA] = useState(lastDraftPromptA)
83
  const [draftPromptB, setDraftPromptB] = useState(lastDraftPromptB)
84
  const draftPrompt = `${draftPromptA}||${draftPromptB}`
@@ -242,6 +243,7 @@ export function TopMenu() {
242
  <div className="flex flex-row flex-grow w-full">
243
  <div className="flex flex-row flex-grow w-full">
244
  <Input
 
245
  placeholder="1. Story (eg. detective dog)"
246
  className={cn(
247
  `w-1/2 rounded-r-none`,
@@ -260,6 +262,7 @@ export function TopMenu() {
260
  value={draftPromptB}
261
  />
262
  <Input
 
263
  placeholder="2. Style (eg 'rain, shiba')"
264
  className={cn(
265
  `w-1/2`,
 
79
  requestedStoryPrompt
80
  )
81
 
82
+ // TODO should be in the store
83
  const [draftPromptA, setDraftPromptA] = useState(lastDraftPromptA)
84
  const [draftPromptB, setDraftPromptB] = useState(lastDraftPromptB)
85
  const draftPrompt = `${draftPromptA}||${draftPromptB}`
 
243
  <div className="flex flex-row flex-grow w-full">
244
  <div className="flex flex-row flex-grow w-full">
245
  <Input
246
+ id="top-menu-input-story-prompt"
247
  placeholder="1. Story (eg. detective dog)"
248
  className={cn(
249
  `w-1/2 rounded-r-none`,
 
262
  value={draftPromptB}
263
  />
264
  <Input
265
+ id="top-menu-input-style-prompt"
266
  placeholder="2. Style (eg 'rain, shiba')"
267
  className={cn(
268
  `w-1/2`,
src/app/main.tsx CHANGED
@@ -121,6 +121,13 @@ export default function Main() {
121
  // console.log(`main.tsx: asked to re-generate!!`)
122
  if (!prompt) { return }
123
 
 
 
 
 
 
 
 
124
  // if the prompt or preset changed, we clear the cache
125
  // this part is important, otherwise when trying to change the prompt
126
  // we wouldn't still have remnants of the previous comic
 
121
  // console.log(`main.tsx: asked to re-generate!!`)
122
  if (!prompt) { return }
123
 
124
+ // a quick and dirty hack to skip prompt regeneration,
125
+ // unless the prompt has really changed
126
+ if (prompt === useStore.getState().currentClap?.meta.description) {
127
+ console.log(`loading a pre-generated comic, so skipping prompt regeneration..`)
128
+ return
129
+ }
130
+
131
  // if the prompt or preset changed, we clear the cache
132
  // this part is important, otherwise when trying to change the prompt
133
  // we wouldn't still have remnants of the previous comic
src/app/store/index.ts CHANGED
@@ -1,14 +1,16 @@
1
  "use client"
2
 
3
  import { create } from "zustand"
4
- import { ClapProject, newClap, newSegment, serializeClap } from "@aitube/clap"
5
 
6
  import { FontName } from "@/lib/fonts"
7
  import { Preset, PresetName, defaultPreset, getPreset, getRandomPreset } from "@/app/engine/presets"
8
  import { RenderedScene } from "@/types"
9
- import { LayoutName, defaultLayout, getRandomLayoutName } from "../layouts"
10
  import { getParam } from "@/lib/getParam"
11
 
 
 
 
12
  export const useStore = create<{
13
  prompt: string
14
  font: FontName
@@ -71,9 +73,17 @@ export const useStore = create<{
71
  // setPage: (page: HTMLDivElement) => void
72
 
73
  generate: (prompt: string, presetName: PresetName, layoutName: LayoutName) => void
74
-
75
  convertComicToClap: () => Promise<ClapProject>
76
-
 
 
 
 
 
 
 
 
 
77
  downloadClap: () => Promise<void>
78
  }>((set, get) => ({
79
  prompt:
@@ -406,6 +416,7 @@ export const useStore = create<{
406
  layouts,
407
  })
408
  },
 
409
  convertComicToClap: async (): Promise<ClapProject> => {
410
  const {
411
  currentNbPanels,
@@ -497,8 +508,120 @@ export const useStore = create<{
497
  return clap
498
  },
499
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  downloadClap: async () => {
501
- const { convertComicToClap } = get()
502
 
503
  const currentClap = await convertComicToClap()
504
 
@@ -513,7 +636,13 @@ export const useStore = create<{
513
  const anchor = document.createElement("a")
514
  anchor.href = objectUrl
515
 
516
- anchor.download = "my_ai_comic.clap"
 
 
 
 
 
 
517
 
518
  document.body.appendChild(anchor) // Append to the body (could be removed once clicked)
519
  anchor.click() // Trigger the download
@@ -521,5 +650,5 @@ export const useStore = create<{
521
  // Cleanup: revoke the object URL and remove the anchor element
522
  URL.revokeObjectURL(objectUrl)
523
  document.body.removeChild(anchor)
524
- }
525
  }))
 
1
  "use client"
2
 
3
  import { create } from "zustand"
4
+ import { ClapProject, ClapSegment, ClapSegmentFilteringMode, filterSegments, newClap, newSegment, parseClap, serializeClap } from "@aitube/clap"
5
 
6
  import { FontName } from "@/lib/fonts"
7
  import { Preset, PresetName, defaultPreset, getPreset, getRandomPreset } from "@/app/engine/presets"
8
  import { RenderedScene } from "@/types"
 
9
  import { getParam } from "@/lib/getParam"
10
 
11
+ import { LayoutName, defaultLayout, getRandomLayoutName } from "../layouts"
12
+ import { putTextInInput } from "@/lib/putTextInInput"
13
+
14
  export const useStore = create<{
15
  prompt: string
16
  font: FontName
 
73
  // setPage: (page: HTMLDivElement) => void
74
 
75
  generate: (prompt: string, presetName: PresetName, layoutName: LayoutName) => void
 
76
  convertComicToClap: () => Promise<ClapProject>
77
+ convertClapToComic: (clap: ClapProject) => Promise<{
78
+ currentNbPanels: number
79
+ prompt: string
80
+ storyPrompt: string
81
+ stylePrompt: string
82
+ panels: string[]
83
+ renderedScenes: Record<string, RenderedScene>
84
+ captions: string[]
85
+ }>
86
+ loadClap: (blob: Blob) => Promise<void>
87
  downloadClap: () => Promise<void>
88
  }>((set, get) => ({
89
  prompt:
 
416
  layouts,
417
  })
418
  },
419
+
420
  convertComicToClap: async (): Promise<ClapProject> => {
421
  const {
422
  currentNbPanels,
 
508
  return clap
509
  },
510
 
511
+ convertClapToComic: async (clap: ClapProject): Promise<{
512
+ currentNbPanels: number
513
+ prompt: string
514
+ storyPrompt: string
515
+ stylePrompt: string
516
+ panels: string[]
517
+ renderedScenes: Record<string, RenderedScene>
518
+ captions: string[]
519
+ }> => {
520
+
521
+ const prompt = clap.meta.description
522
+ const [stylePrompt, storyPrompt] = prompt.split("||").map(x => x.trim())
523
+
524
+ const panels: string[] = []
525
+ const renderedScenes: Record<string, RenderedScene> = {}
526
+ const captions: string[] = []
527
+
528
+ const panelGenerationStatus: Record<number, boolean> = {}
529
+
530
+ const cameraShots = clap.segments.filter(s => s.category === "camera")
531
+
532
+ const shots = cameraShots.map(cameraShot => ({
533
+ camera: cameraShot,
534
+ storyboard: filterSegments(
535
+ ClapSegmentFilteringMode.START,
536
+ cameraShot,
537
+ clap.segments,
538
+ "storyboard"
539
+ ).at(0) as (ClapSegment | undefined),
540
+ ui: filterSegments(
541
+ ClapSegmentFilteringMode.START,
542
+ cameraShot,
543
+ clap.segments,
544
+ "interface"
545
+ ).at(0) as (ClapSegment | undefined)
546
+ })).filter(item => item.storyboard && item.ui) as {
547
+ camera: ClapSegment
548
+ storyboard: ClapSegment
549
+ ui: ClapSegment
550
+ }[]
551
+
552
+ shots.forEach(({ camera, storyboard, ui }, id) => {
553
+
554
+ panels.push(storyboard.prompt)
555
+
556
+ const renderedScene: RenderedScene = {
557
+ renderId: storyboard.id,
558
+ status: "pending",
559
+ assetUrl: "",
560
+ alt: storyboard.prompt,
561
+ error: "",
562
+ maskUrl: "",
563
+ segments: []
564
+ }
565
+
566
+ if (storyboard.assetUrl) {
567
+ renderedScene.assetUrl = storyboard.assetUrl
568
+ renderedScene.status = "pregenerated" // <- special trick to indicate that it should not be re-generated
569
+ }
570
+
571
+ renderedScenes[id] = renderedScene
572
+
573
+ panelGenerationStatus[id] = false
574
+
575
+ captions.push(ui.prompt)
576
+ })
577
+
578
+ return {
579
+ currentNbPanels: shots.length,
580
+ prompt,
581
+ storyPrompt,
582
+ stylePrompt,
583
+ panels,
584
+ renderedScenes,
585
+ captions,
586
+
587
+ }
588
+ },
589
+
590
+ loadClap: async (blob: Blob) => {
591
+ const { convertClapToComic, currentNbPanelsPerPage } = get()
592
+
593
+ const currentClap = await parseClap(blob)
594
+
595
+ const {
596
+ currentNbPanels,
597
+ prompt,
598
+ storyPrompt,
599
+ stylePrompt,
600
+ panels,
601
+ renderedScenes,
602
+ captions,
603
+ } = await convertClapToComic(currentClap)
604
+
605
+ // kids, don't do this in your projects: use state managers instead!
606
+ putTextInInput(document.getElementById("top-menu-input-style-prompt") as HTMLInputElement, stylePrompt)
607
+ putTextInInput(document.getElementById("top-menu-input-story-prompt") as HTMLInputElement, storyPrompt)
608
+
609
+ set({
610
+ currentClap,
611
+ currentNbPanels,
612
+ prompt,
613
+ panels,
614
+ renderedScenes,
615
+ captions,
616
+ currentNbPages: Math.round(currentNbPanels / currentNbPanelsPerPage),
617
+ upscaleQueue: {},
618
+ isGeneratingStory: false,
619
+ isGeneratingText: false,
620
+ })
621
+ },
622
+
623
  downloadClap: async () => {
624
+ const { convertComicToClap, prompt } = get()
625
 
626
  const currentClap = await convertComicToClap()
627
 
 
636
  const anchor = document.createElement("a")
637
  anchor.href = objectUrl
638
 
639
+ const [stylePrompt, storyPrompt] = prompt.split("||").map(x => x.trim())
640
+
641
+ const cleanStylePrompt = stylePrompt.replace(/([a-z0-9_,]+)/gi, "_")
642
+ const cleanStoryPrompt = storyPrompt.replace(/([a-z0-9_,]+)/gi, "_")
643
+ const cleanName = `${cleanStoryPrompt.slice(0, 20)} (${cleanStylePrompt.slice(0, 20) || "default style"})`
644
+
645
+ anchor.download = `${cleanName}.clap`
646
 
647
  document.body.appendChild(anchor) // Append to the body (could be removed once clicked)
648
  anchor.click() // Trigger the download
 
650
  // Cleanup: revoke the object URL and remove the anchor element
651
  URL.revokeObjectURL(objectUrl)
652
  document.body.removeChild(anchor)
653
+ },
654
  }))
src/lib/fileToBase64.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export function fileToBase64(file: File | Blob): Promise<string> {
2
+ return new Promise((resolve, reject) => {
3
+ const fileReader = new FileReader();
4
+ fileReader.readAsDataURL(file);
5
+ fileReader.onload = () => { resolve(`${fileReader.result}`); };
6
+ fileReader.onerror = (error) => { reject(error); };
7
+ });
8
+ }
src/lib/putTextInInput.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function putTextInInput(input?: HTMLInputElement, text: string = "") {
2
+ if (!input) { return }
3
+
4
+ const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
5
+ window.HTMLInputElement.prototype,
6
+ "value"
7
+ )?.set;
8
+
9
+ // fallback
10
+ if (!nativeTextAreaValueSetter) {
11
+ input.value = text
12
+ return
13
+ }
14
+
15
+ nativeTextAreaValueSetter.call(input, text)
16
+ const event = new Event('input', { bubbles: true });
17
+ input.dispatchEvent(event)
18
+ }
src/types.ts CHANGED
@@ -61,6 +61,7 @@ export interface ImageSegment {
61
  }
62
 
63
  export type RenderedSceneStatus =
 
64
  | "pending"
65
  | "completed"
66
  | "error"
 
61
  }
62
 
63
  export type RenderedSceneStatus =
64
+ | "pregenerated"
65
  | "pending"
66
  | "completed"
67
  | "error"