Spaces:
Running
Running
Commit
•
cb3fdda
1
Parent(s):
35380c3
adding some counter measures to reduce the pressure on the server
Browse files- public/bubble.jpg +0 -0
- public/mask.png +0 -0
- scripts/test.js +0 -23
- src/app/engine/render.ts +5 -3
- src/app/interface/bottom-bar/index.tsx +3 -1
- src/app/interface/page/index.tsx +50 -0
- src/app/interface/panel/index.tsx +45 -23
- src/app/interface/progress/index.tsx +1 -1
- src/app/interface/zoom/index.tsx +1 -1
- src/app/layouts/index.tsx +18 -11
- src/app/layouts/new_layouts.tsx +0 -0
- src/app/main.tsx +40 -39
- src/app/queries/getStory.ts +2 -2
- src/app/store/index.ts +20 -8
- src/lib/cropImage.ts +53 -0
- src/lib/loadImage.ts +14 -0
- src/lib/replaceNonWhiteWithTransparent.ts +46 -0
- src/lib/replaceWhiteWithTransparent.ts +37 -0
- src/lib/writeIntoBubble.ts +115 -0
- src/lib/writeIntoBubbles.ts +65 -0
public/bubble.jpg
ADDED
public/mask.png
ADDED
scripts/test.js
DELETED
@@ -1,23 +0,0 @@
|
|
1 |
-
const { promises: fs } = require("node:fs")
|
2 |
-
|
3 |
-
const main = async () => {
|
4 |
-
console.log('generating shot..')
|
5 |
-
const response = await fetch("http://localhost:3000/api/shot", {
|
6 |
-
method: "POST",
|
7 |
-
headers: {
|
8 |
-
"Accept": "application/json",
|
9 |
-
"Content-Type": "application/json"
|
10 |
-
},
|
11 |
-
body: JSON.stringify({
|
12 |
-
token: process.env.VC_SECRET_ACCESS_TOKEN,
|
13 |
-
shotPrompt: "video of a dancing cat"
|
14 |
-
})
|
15 |
-
});
|
16 |
-
|
17 |
-
console.log('response:', response)
|
18 |
-
const buffer = await response.buffer()
|
19 |
-
|
20 |
-
fs.writeFile(`./test-juju.mp4`, buffer)
|
21 |
-
}
|
22 |
-
|
23 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/engine/render.ts
CHANGED
@@ -51,14 +51,16 @@ export async function newRender({
|
|
51 |
// negativePrompt, unused for now
|
52 |
nbFrames: 1,
|
53 |
nbSteps: 25, // 20 = fast, 30 = better, 50 = best
|
54 |
-
actionnables: [],
|
55 |
-
segmentation: "disabled", // one day we will remove this param, to make it automatic
|
56 |
width,
|
57 |
height,
|
58 |
|
59 |
// no need to upscale right now as we generate tiny panels
|
60 |
// maybe later we can provide an "export" button to PDF
|
61 |
-
|
|
|
|
|
62 |
|
63 |
// analyzing doesn't work yet, it seems..
|
64 |
analyze: false, // analyze: true,
|
|
|
51 |
// negativePrompt, unused for now
|
52 |
nbFrames: 1,
|
53 |
nbSteps: 25, // 20 = fast, 30 = better, 50 = best
|
54 |
+
actionnables: [], // ["text block"],
|
55 |
+
segmentation: "disabled", // "firstframe", // one day we will remove this param, to make it automatic
|
56 |
width,
|
57 |
height,
|
58 |
|
59 |
// no need to upscale right now as we generate tiny panels
|
60 |
// maybe later we can provide an "export" button to PDF
|
61 |
+
// unfortunately there are too many requests for upscaling,
|
62 |
+
// the server is always down
|
63 |
+
upscalingFactor: 1, // 2,
|
64 |
|
65 |
// analyzing doesn't work yet, it seems..
|
66 |
analyze: false, // analyze: true,
|
src/app/interface/bottom-bar/index.tsx
CHANGED
@@ -28,7 +28,9 @@ export function BottomBar() {
|
|
28 |
onClick={handlePrint}
|
29 |
disabled={!prompt?.length}
|
30 |
>{
|
31 |
-
remainingImages ?
|
|
|
|
|
32 |
}</Button>
|
33 |
</div>
|
34 |
</div>
|
|
|
28 |
onClick={handlePrint}
|
29 |
disabled={!prompt?.length}
|
30 |
>{
|
31 |
+
remainingImages ? `${allStatus.length - remainingImages}/4 panels ⌛` : `Print as PDF`
|
32 |
+
// remainingImages ? `Print (${allStatus.length - remainingImages}/4 in HD ⌛)` : `Print (in HD)`
|
33 |
+
|
34 |
}</Button>
|
35 |
</div>
|
36 |
</div>
|
src/app/interface/page/index.tsx
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { allLayouts } from "@/app/layouts"
|
2 |
+
import { useStore } from "@/app/store"
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
import { useEffect, useState } from "react"
|
5 |
+
|
6 |
+
|
7 |
+
export function Page({ page }: { page: number }) {
|
8 |
+
const zoomLevel = useStore(state => state.zoomLevel)
|
9 |
+
const layouts = useStore(state => state.layouts)
|
10 |
+
const prompt = useStore(state => state.prompt)
|
11 |
+
|
12 |
+
const LayoutElement = (allLayouts as any)[layouts[page]]
|
13 |
+
|
14 |
+
/*
|
15 |
+
const [canLoad, setCanLoad] = useState(false)
|
16 |
+
useEffect(() => {
|
17 |
+
if (prompt?.length) {
|
18 |
+
setCanLoad(false)
|
19 |
+
setTimeout(() => {
|
20 |
+
setCanLoad(true)
|
21 |
+
}, page * 4000)
|
22 |
+
}
|
23 |
+
}, [prompt])
|
24 |
+
*/
|
25 |
+
|
26 |
+
return (
|
27 |
+
<div
|
28 |
+
className={cn(
|
29 |
+
`w-full`,
|
30 |
+
// we are trying to reach a "book" look
|
31 |
+
// we are using aspect-[297/210] because it matches A4 (297mm x 210mm)
|
32 |
+
// `aspect-[210/297]`,
|
33 |
+
`aspect-[250/297]`,
|
34 |
+
|
35 |
+
`transition-all duration-100 ease-in-out`,
|
36 |
+
`border border-stone-200`,
|
37 |
+
`shadow-2xl`,
|
38 |
+
`print:shadow-none`,
|
39 |
+
`print:border-0`,
|
40 |
+
`print:width-screen`
|
41 |
+
)}
|
42 |
+
style={{
|
43 |
+
padding: `${Math.round((zoomLevel / 100) * 16)}px`
|
44 |
+
// marginLeft: `${zoomLevel > 100 ? `100`}`
|
45 |
+
}}
|
46 |
+
>
|
47 |
+
<LayoutElement />
|
48 |
+
</div>
|
49 |
+
)
|
50 |
+
}
|
src/app/interface/panel/index.tsx
CHANGED
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"
|
|
12 |
import { getInitialRenderedScene } from "@/lib/getInitialRenderedScene"
|
13 |
import { Progress } from "@/app/interface/progress"
|
14 |
import { see } from "@/app/engine/caption"
|
|
|
15 |
// import { Bubble } from "./bubble"
|
16 |
|
17 |
export function Panel({
|
@@ -49,23 +50,25 @@ export function Panel({
|
|
49 |
// since this run in its own loop, we need to use references everywhere
|
50 |
// but perhaps this could be refactored
|
51 |
useEffect(() => {
|
52 |
-
|
53 |
-
|
54 |
-
if (!prompt?.length) { return }
|
55 |
-
|
56 |
-
console.log("Loading panel..")
|
57 |
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
})
|
62 |
|
63 |
-
|
64 |
-
|
65 |
-
setGeneratingImages(panel, true)
|
66 |
-
setRendered(getInitialRenderedScene())
|
67 |
|
68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
|
70 |
if (newRendered) {
|
71 |
// console.log("newRendered:", newRendered)
|
@@ -86,6 +89,7 @@ export function Panel({
|
|
86 |
return
|
87 |
}
|
88 |
})
|
|
|
89 |
}, [prompt, width, height])
|
90 |
|
91 |
|
@@ -94,7 +98,7 @@ export function Panel({
|
|
94 |
clearTimeout(timeoutRef.current)
|
95 |
|
96 |
if (!renderedRef.current?.renderId || renderedRef.current?.status !== "pending") {
|
97 |
-
timeoutRef.current = setTimeout(checkStatus,
|
98 |
return
|
99 |
}
|
100 |
try {
|
@@ -112,20 +116,20 @@ export function Panel({
|
|
112 |
|
113 |
if (newRendered.status === "pending") {
|
114 |
// console.log("job not finished")
|
115 |
-
timeoutRef.current = setTimeout(checkStatus,
|
116 |
} else {
|
117 |
console.log("panel finished!")
|
118 |
setGeneratingImages(panel, false)
|
119 |
}
|
120 |
} catch (err) {
|
121 |
console.error(err)
|
122 |
-
timeoutRef.current = setTimeout(checkStatus,
|
123 |
}
|
124 |
})
|
125 |
}
|
126 |
|
127 |
useEffect(() => {
|
128 |
-
console.log("starting timeout")
|
129 |
clearTimeout(timeoutRef.current)
|
130 |
|
131 |
// normally it should reply in < 1sec, but we could also use an interval
|
@@ -176,6 +180,22 @@ export function Panel({
|
|
176 |
`print:border-[1.5px] print:shadow-none`,
|
177 |
)
|
178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
if (prompt && !rendered.assetUrl) {
|
180 |
return (
|
181 |
<div className={cn(
|
@@ -194,11 +214,13 @@ export function Panel({
|
|
194 |
{ "grayscale": preset.color === "grayscale" },
|
195 |
className
|
196 |
)}>
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
|
|
|
|
202 |
|
203 |
{/*<Bubble className="absolute top-4 left-4">
|
204 |
Hello, world!
|
|
|
12 |
import { getInitialRenderedScene } from "@/lib/getInitialRenderedScene"
|
13 |
import { Progress } from "@/app/interface/progress"
|
14 |
import { see } from "@/app/engine/caption"
|
15 |
+
import { writeIntoBubble } from "@/lib/writeIntoBubble"
|
16 |
// import { Bubble } from "./bubble"
|
17 |
|
18 |
export function Panel({
|
|
|
50 |
// since this run in its own loop, we need to use references everywhere
|
51 |
// but perhaps this could be refactored
|
52 |
useEffect(() => {
|
53 |
+
// console.log("Panel prompt: "+ prompt)
|
54 |
+
if (!prompt?.length) { return }
|
|
|
|
|
|
|
55 |
|
56 |
+
// important: update the status, and clear the scene
|
57 |
+
setGeneratingImages(panel, true)
|
58 |
+
setRendered(getInitialRenderedScene())
|
|
|
59 |
|
60 |
+
setTimeout(() => {
|
61 |
+
startTransition(async () => {
|
|
|
|
|
62 |
|
63 |
+
console.log(`Loading panel ${panel}..`)
|
64 |
+
|
65 |
+
let newRendered = await newRender({ prompt, width, height })
|
66 |
+
try {
|
67 |
+
newRendered = await newRender({ prompt, width, height })
|
68 |
+
} catch (err) {
|
69 |
+
console.log("Failed to load the panel! Don't worry, we are retrying..")
|
70 |
+
newRendered = await newRender({ prompt, width, height })
|
71 |
+
}
|
72 |
|
73 |
if (newRendered) {
|
74 |
// console.log("newRendered:", newRendered)
|
|
|
89 |
return
|
90 |
}
|
91 |
})
|
92 |
+
}, 1000 * panel)
|
93 |
}, [prompt, width, height])
|
94 |
|
95 |
|
|
|
98 |
clearTimeout(timeoutRef.current)
|
99 |
|
100 |
if (!renderedRef.current?.renderId || renderedRef.current?.status !== "pending") {
|
101 |
+
timeoutRef.current = setTimeout(checkStatus, 1500)
|
102 |
return
|
103 |
}
|
104 |
try {
|
|
|
116 |
|
117 |
if (newRendered.status === "pending") {
|
118 |
// console.log("job not finished")
|
119 |
+
timeoutRef.current = setTimeout(checkStatus, 1500)
|
120 |
} else {
|
121 |
console.log("panel finished!")
|
122 |
setGeneratingImages(panel, false)
|
123 |
}
|
124 |
} catch (err) {
|
125 |
console.error(err)
|
126 |
+
timeoutRef.current = setTimeout(checkStatus, 1500)
|
127 |
}
|
128 |
})
|
129 |
}
|
130 |
|
131 |
useEffect(() => {
|
132 |
+
// console.log("starting timeout")
|
133 |
clearTimeout(timeoutRef.current)
|
134 |
|
135 |
// normally it should reply in < 1sec, but we could also use an interval
|
|
|
180 |
`print:border-[1.5px] print:shadow-none`,
|
181 |
)
|
182 |
|
183 |
+
const [newMask, setNewMask] = useState("")
|
184 |
+
|
185 |
+
useEffect(() => {
|
186 |
+
const transformMask = async () => {
|
187 |
+
if (rendered.maskUrl) {
|
188 |
+
const imgSrc = await writeIntoBubble(
|
189 |
+
rendered.maskUrl,
|
190 |
+
"LOREM IPSUM! Dolor sit amet.."
|
191 |
+
)
|
192 |
+
setNewMask(imgSrc)
|
193 |
+
}
|
194 |
+
}
|
195 |
+
transformMask()
|
196 |
+
}, [rendered.maskUrl])
|
197 |
+
|
198 |
+
|
199 |
if (prompt && !rendered.assetUrl) {
|
200 |
return (
|
201 |
<div className={cn(
|
|
|
214 |
{ "grayscale": preset.color === "grayscale" },
|
215 |
className
|
216 |
)}>
|
217 |
+
{rendered.assetUrl && <img
|
218 |
+
src={rendered.assetUrl}
|
219 |
+
className=" w-full h-full object-cover"
|
220 |
+
alt={rendered.alt}
|
221 |
+
/>}
|
222 |
+
|
223 |
+
|
224 |
|
225 |
{/*<Bubble className="absolute top-4 left-4">
|
226 |
Hello, world!
|
src/app/interface/progress/index.tsx
CHANGED
@@ -24,7 +24,7 @@ export function Progress({
|
|
24 |
|
25 |
// normally it takes 45, and we will try to go below,
|
26 |
// but to be safe let's set the counter a 1 min
|
27 |
-
const nbSeconds =
|
28 |
const amountInPercent = 100 / (nbUpdatesPerSec * nbSeconds) // 0.333
|
29 |
|
30 |
progressRef.current = Math.min(100, progressRef.current + amountInPercent)
|
|
|
24 |
|
25 |
// normally it takes 45, and we will try to go below,
|
26 |
// but to be safe let's set the counter a 1 min
|
27 |
+
const nbSeconds = 60 // 1 min
|
28 |
const amountInPercent = 100 / (nbUpdatesPerSec * nbSeconds) // 0.333
|
29 |
|
30 |
progressRef.current = Math.min(100, progressRef.current + amountInPercent)
|
src/app/interface/zoom/index.tsx
CHANGED
@@ -15,7 +15,7 @@ export function Zoom() {
|
|
15 |
`animation-all duration-300 ease-in-out`,
|
16 |
isGeneratingStory ? `scale-0 opacity-0` : ``,
|
17 |
)}>
|
18 |
-
<div className="font-mono text-xs pb-1 text-stone-700">
|
19 |
Zoom
|
20 |
</div>
|
21 |
<div className="w-2">
|
|
|
15 |
`animation-all duration-300 ease-in-out`,
|
16 |
isGeneratingStory ? `scale-0 opacity-0` : ``,
|
17 |
)}>
|
18 |
+
<div className="font-mono text-xs pb-1 text-stone-700 bg-stone-50 rounded-full">
|
19 |
Zoom
|
20 |
</div>
|
21 |
<div className="w-2">
|
src/app/layouts/index.tsx
CHANGED
@@ -17,14 +17,14 @@ export function Layout1() {
|
|
17 |
<div className="bg-zinc-100 row-span-2">
|
18 |
<Panel
|
19 |
panel={1}
|
20 |
-
width={
|
21 |
height={1024}
|
22 |
/>
|
23 |
</div>
|
24 |
<div className="bg-gray-100 row-span-2 col-span-1">
|
25 |
<Panel
|
26 |
panel={2}
|
27 |
-
width={
|
28 |
height={1024}
|
29 |
/>
|
30 |
</div>
|
@@ -154,20 +154,20 @@ export function Layout5() {
|
|
154 |
<Panel
|
155 |
panel={0}
|
156 |
width={768}
|
157 |
-
height={
|
158 |
/>
|
159 |
</div>
|
160 |
<div className="bg-zinc-100 col-span-1 row-span-1">
|
161 |
<Panel
|
162 |
panel={1}
|
163 |
width={768}
|
164 |
-
height={
|
165 |
/>
|
166 |
</div>
|
167 |
<div className="bg-stone-100 row-span-2 col-span-1">
|
168 |
<Panel
|
169 |
panel={2}
|
170 |
-
width={
|
171 |
height={1024}
|
172 |
/>
|
173 |
</div>
|
@@ -175,7 +175,7 @@ export function Layout5() {
|
|
175 |
<Panel
|
176 |
panel={3}
|
177 |
width={1024}
|
178 |
-
height={
|
179 |
/>
|
180 |
</div>
|
181 |
</Grid>
|
@@ -196,14 +196,14 @@ export function Layout6() {
|
|
196 |
<Panel
|
197 |
panel={1}
|
198 |
width={768}
|
199 |
-
height={
|
200 |
/>
|
201 |
</div>
|
202 |
<div className="bg-stone-100 row-span-1 col-span-1">
|
203 |
<Panel
|
204 |
panel={2}
|
205 |
width={768}
|
206 |
-
height={
|
207 |
/>
|
208 |
</div>
|
209 |
<div className="bg-slate-100 row-span-1 col-span-2">
|
@@ -218,11 +218,18 @@ export function Layout6() {
|
|
218 |
}
|
219 |
|
220 |
// export const layouts = { Layout1, Layout2, Layout3, Layout4, Layout5, Layout6 }
|
221 |
-
export const
|
|
|
|
|
|
|
|
|
222 |
|
223 |
-
export type LayoutName = keyof typeof
|
224 |
|
225 |
export function getRandomLayoutName(): LayoutName {
|
226 |
-
return pick(Object.keys(
|
227 |
}
|
228 |
|
|
|
|
|
|
|
|
17 |
<div className="bg-zinc-100 row-span-2">
|
18 |
<Panel
|
19 |
panel={1}
|
20 |
+
width={768}
|
21 |
height={1024}
|
22 |
/>
|
23 |
</div>
|
24 |
<div className="bg-gray-100 row-span-2 col-span-1">
|
25 |
<Panel
|
26 |
panel={2}
|
27 |
+
width={768}
|
28 |
height={1024}
|
29 |
/>
|
30 |
</div>
|
|
|
154 |
<Panel
|
155 |
panel={0}
|
156 |
width={768}
|
157 |
+
height={1024}
|
158 |
/>
|
159 |
</div>
|
160 |
<div className="bg-zinc-100 col-span-1 row-span-1">
|
161 |
<Panel
|
162 |
panel={1}
|
163 |
width={768}
|
164 |
+
height={1024}
|
165 |
/>
|
166 |
</div>
|
167 |
<div className="bg-stone-100 row-span-2 col-span-1">
|
168 |
<Panel
|
169 |
panel={2}
|
170 |
+
width={512}
|
171 |
height={1024}
|
172 |
/>
|
173 |
</div>
|
|
|
175 |
<Panel
|
176 |
panel={3}
|
177 |
width={1024}
|
178 |
+
height={1024}
|
179 |
/>
|
180 |
</div>
|
181 |
</Grid>
|
|
|
196 |
<Panel
|
197 |
panel={1}
|
198 |
width={768}
|
199 |
+
height={1024}
|
200 |
/>
|
201 |
</div>
|
202 |
<div className="bg-stone-100 row-span-1 col-span-1">
|
203 |
<Panel
|
204 |
panel={2}
|
205 |
width={768}
|
206 |
+
height={1024}
|
207 |
/>
|
208 |
</div>
|
209 |
<div className="bg-slate-100 row-span-1 col-span-2">
|
|
|
218 |
}
|
219 |
|
220 |
// export const layouts = { Layout1, Layout2, Layout3, Layout4, Layout5, Layout6 }
|
221 |
+
export const allLayouts = {
|
222 |
+
Layout1,
|
223 |
+
Layout5,
|
224 |
+
Layout6
|
225 |
+
}
|
226 |
|
227 |
+
export type LayoutName = keyof typeof allLayouts
|
228 |
|
229 |
export function getRandomLayoutName(): LayoutName {
|
230 |
+
return pick(Object.keys(allLayouts) as LayoutName[]) as LayoutName
|
231 |
}
|
232 |
|
233 |
+
export function getRandomLayoutNames(): LayoutName[] {
|
234 |
+
return Object.keys(allLayouts).sort(() => Math.random() - 0.5) as LayoutName[]
|
235 |
+
}
|
src/app/layouts/new_layouts.tsx
ADDED
File without changes
|
src/app/main.tsx
CHANGED
@@ -1,18 +1,19 @@
|
|
1 |
"use client"
|
2 |
|
3 |
-
import { useEffect, useRef, useTransition } from "react"
|
4 |
import { useSearchParams } from "next/navigation"
|
5 |
|
6 |
import { PresetName, defaultPreset, getPreset } from "@/app/engine/presets"
|
7 |
|
8 |
import { cn } from "@/lib/utils"
|
9 |
import { TopMenu } from "./interface/top-menu"
|
10 |
-
import { FontName, defaultFont,
|
11 |
-
import { getRandomLayoutName
|
12 |
import { useStore } from "./store"
|
13 |
import { Zoom } from "./interface/zoom"
|
14 |
import { getStory } from "./queries/getStory"
|
15 |
import { BottomBar } from "./interface/bottom-bar"
|
|
|
16 |
|
17 |
export default function Main() {
|
18 |
const [_isPending, startTransition] = useTransition()
|
@@ -34,8 +35,7 @@ export default function Main() {
|
|
34 |
const prompt = useStore(state => state.prompt)
|
35 |
const setPrompt = useStore(state => state.setPrompt)
|
36 |
|
37 |
-
const
|
38 |
-
const setLayout = useStore(state => state.setLayout)
|
39 |
|
40 |
const setPanels = useStore(state => state.setPanels)
|
41 |
|
@@ -44,6 +44,8 @@ export default function Main() {
|
|
44 |
const setPage = useStore(state => state.setPage)
|
45 |
const pageRef = useRef<HTMLDivElement>(null)
|
46 |
|
|
|
|
|
47 |
useEffect(() => {
|
48 |
const element = pageRef.current
|
49 |
if (!element) { return }
|
@@ -68,23 +70,28 @@ export default function Main() {
|
|
68 |
if (!prompt) { return }
|
69 |
|
70 |
startTransition(async () => {
|
71 |
-
|
72 |
setGeneratingStory(true)
|
73 |
|
74 |
-
const
|
75 |
-
|
76 |
-
|
|
|
|
|
|
|
|
|
77 |
|
78 |
try {
|
|
|
79 |
const llmResponse = await getStory({ preset, prompt })
|
80 |
console.log("response:", llmResponse)
|
81 |
|
82 |
-
// TODO call the LLM here!
|
83 |
const panelPromptPrefix = preset.imagePrompt(prompt).join(", ")
|
84 |
console.log("panel prompt prefix:", panelPromptPrefix)
|
85 |
|
86 |
const nbPanels = 4
|
87 |
const newPanels: string[] = []
|
|
|
88 |
|
89 |
for (let p = 0; p < nbPanels; p++) {
|
90 |
const newPanel = [panelPromptPrefix, llmResponse[p] || ""]
|
@@ -95,13 +102,14 @@ export default function Main() {
|
|
95 |
} catch (err) {
|
96 |
console.error(err)
|
97 |
} finally {
|
98 |
-
|
|
|
|
|
|
|
99 |
}
|
100 |
})
|
101 |
}, [prompt, preset?.label]) // important: we need to react to preset changes too
|
102 |
|
103 |
-
const LayoutElement = (layouts as any)[layout]
|
104 |
-
|
105 |
return (
|
106 |
<div>
|
107 |
<TopMenu />
|
@@ -112,35 +120,27 @@ export default function Main() {
|
|
112 |
`print:pt-0 print:px-0 print:pl-0 print:pr-0`,
|
113 |
fonts.actionman.className
|
114 |
)}>
|
115 |
-
<div
|
116 |
-
|
117 |
-
|
118 |
-
|
|
|
|
|
119 |
<div
|
120 |
-
ref={pageRef}
|
121 |
className={cn(
|
122 |
`comic-page`,
|
123 |
-
`flex flex-col items-center justify-start`,
|
124 |
-
|
125 |
-
// we are trying to reach a "book" look
|
126 |
-
// we are using aspect-[297/210] because it matches A4 (297mm x 210mm)
|
127 |
-
// `aspect-[210/297]`,
|
128 |
-
`aspect-[250/297]`,
|
129 |
-
|
130 |
-
`transition-all duration-100 ease-in-out`,
|
131 |
-
`border border-stone-200`,
|
132 |
-
`shadow-2xl`,
|
133 |
-
`print:shadow-none`,
|
134 |
-
`print:border-0`,
|
135 |
-
`print:width-screen`
|
136 |
)}
|
137 |
style={{
|
138 |
-
width: `${zoomLevel}
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
|
|
|
|
|
|
144 |
</div>
|
145 |
</div>
|
146 |
</div>
|
@@ -157,11 +157,12 @@ export default function Main() {
|
|
157 |
fonts.actionman.className
|
158 |
)}>
|
159 |
<div className={cn(
|
160 |
-
`text-center text-
|
161 |
isGeneratingStory ? ``: `scale-0 opacity-0`,
|
162 |
`transition-all duration-300 ease-in-out`,
|
163 |
)}>
|
164 |
-
Generating
|
|
|
165 |
</div>
|
166 |
</div>
|
167 |
</div>
|
|
|
1 |
"use client"
|
2 |
|
3 |
+
import { useEffect, useRef, useState, useTransition } from "react"
|
4 |
import { useSearchParams } from "next/navigation"
|
5 |
|
6 |
import { PresetName, defaultPreset, getPreset } from "@/app/engine/presets"
|
7 |
|
8 |
import { cn } from "@/lib/utils"
|
9 |
import { TopMenu } from "./interface/top-menu"
|
10 |
+
import { FontName, defaultFont, fonts } from "@/lib/fonts"
|
11 |
+
import { getRandomLayoutName } from "./layouts"
|
12 |
import { useStore } from "./store"
|
13 |
import { Zoom } from "./interface/zoom"
|
14 |
import { getStory } from "./queries/getStory"
|
15 |
import { BottomBar } from "./interface/bottom-bar"
|
16 |
+
import { Page } from "./interface/page"
|
17 |
|
18 |
export default function Main() {
|
19 |
const [_isPending, startTransition] = useTransition()
|
|
|
35 |
const prompt = useStore(state => state.prompt)
|
36 |
const setPrompt = useStore(state => state.setPrompt)
|
37 |
|
38 |
+
const setLayouts = useStore(state => state.setLayouts)
|
|
|
39 |
|
40 |
const setPanels = useStore(state => state.setPanels)
|
41 |
|
|
|
44 |
const setPage = useStore(state => state.setPage)
|
45 |
const pageRef = useRef<HTMLDivElement>(null)
|
46 |
|
47 |
+
const [waitABitMore, setWaitABitMore] = useState(false)
|
48 |
+
|
49 |
useEffect(() => {
|
50 |
const element = pageRef.current
|
51 |
if (!element) { return }
|
|
|
70 |
if (!prompt) { return }
|
71 |
|
72 |
startTransition(async () => {
|
73 |
+
setWaitABitMore(false)
|
74 |
setGeneratingStory(true)
|
75 |
|
76 |
+
const newLayouts = [
|
77 |
+
getRandomLayoutName(),
|
78 |
+
getRandomLayoutName(),
|
79 |
+
]
|
80 |
+
|
81 |
+
console.log("using layouts " + newLayouts)
|
82 |
+
setLayouts(newLayouts)
|
83 |
|
84 |
try {
|
85 |
+
|
86 |
const llmResponse = await getStory({ preset, prompt })
|
87 |
console.log("response:", llmResponse)
|
88 |
|
|
|
89 |
const panelPromptPrefix = preset.imagePrompt(prompt).join(", ")
|
90 |
console.log("panel prompt prefix:", panelPromptPrefix)
|
91 |
|
92 |
const nbPanels = 4
|
93 |
const newPanels: string[] = []
|
94 |
+
setWaitABitMore(true)
|
95 |
|
96 |
for (let p = 0; p < nbPanels; p++) {
|
97 |
const newPanel = [panelPromptPrefix, llmResponse[p] || ""]
|
|
|
102 |
} catch (err) {
|
103 |
console.error(err)
|
104 |
} finally {
|
105 |
+
setTimeout(() => {
|
106 |
+
setGeneratingStory(false)
|
107 |
+
setWaitABitMore(false)
|
108 |
+
}, 9000)
|
109 |
}
|
110 |
})
|
111 |
}, [prompt, preset?.label]) // important: we need to react to preset changes too
|
112 |
|
|
|
|
|
113 |
return (
|
114 |
<div>
|
115 |
<TopMenu />
|
|
|
120 |
`print:pt-0 print:px-0 print:pl-0 print:pr-0`,
|
121 |
fonts.actionman.className
|
122 |
)}>
|
123 |
+
<div
|
124 |
+
ref={pageRef}
|
125 |
+
className={cn(
|
126 |
+
`flex flex-col w-full`,
|
127 |
+
zoomLevel > 105 ? `items-start` : `items-center`
|
128 |
+
)}>
|
129 |
<div
|
|
|
130 |
className={cn(
|
131 |
`comic-page`,
|
132 |
+
`flex flex-col md:flex-row md:space-x-16 md:items-center md:justify-start`,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
133 |
)}
|
134 |
style={{
|
135 |
+
width: `${zoomLevel}%`
|
136 |
+
}}>
|
137 |
+
<Page page={0} />
|
138 |
+
|
139 |
+
{/*
|
140 |
+
// we could support multiple pages here,
|
141 |
+
// but let's disable it for now
|
142 |
+
<Page page={1} />
|
143 |
+
*/}
|
144 |
</div>
|
145 |
</div>
|
146 |
</div>
|
|
|
157 |
fonts.actionman.className
|
158 |
)}>
|
159 |
<div className={cn(
|
160 |
+
`text-center text-xl text-stone-600 w-[70%]`,
|
161 |
isGeneratingStory ? ``: `scale-0 opacity-0`,
|
162 |
`transition-all duration-300 ease-in-out`,
|
163 |
)}>
|
164 |
+
{waitABitMore ? `Story is ready, but server is a bit busy!`: 'Generating a new story..'}<br/>
|
165 |
+
{waitABitMore ? `Please hold tight..` : ''}
|
166 |
</div>
|
167 |
</div>
|
168 |
</div>
|
src/app/queries/getStory.ts
CHANGED
@@ -19,10 +19,10 @@ export const getStory = async ({
|
|
19 |
role: "system",
|
20 |
content: [
|
21 |
`You are a comic book author specialized in ${preset.llmPrompt}`,
|
22 |
-
`Please
|
23 |
`Give your response as a JSON array like this: \`Array<{ panel: number; caption: string}>\`.`,
|
24 |
// `Give your response as Markdown bullet points.`,
|
25 |
-
`Be brief in your
|
26 |
].filter(item => item).join("\n")
|
27 |
},
|
28 |
{
|
|
|
19 |
role: "system",
|
20 |
content: [
|
21 |
`You are a comic book author specialized in ${preset.llmPrompt}`,
|
22 |
+
`Please write detailed drawing instructions for the 5 panels of a new silent comic book page.`,
|
23 |
`Give your response as a JSON array like this: \`Array<{ panel: number; caption: string}>\`.`,
|
24 |
// `Give your response as Markdown bullet points.`,
|
25 |
+
`Be brief in your 5 captions, don't add your own comments. Be straight to the point, and never reply things like "Sure, I can.." etc.`
|
26 |
].filter(item => item).join("\n")
|
27 |
},
|
28 |
{
|
src/app/store/index.ts
CHANGED
@@ -4,16 +4,17 @@ import { create } from "zustand"
|
|
4 |
|
5 |
import { FontName } from "@/lib/fonts"
|
6 |
import { Preset, getPreset } from "@/app/engine/presets"
|
7 |
-
import { LayoutName,
|
8 |
import html2canvas from "html2canvas"
|
9 |
|
10 |
export const useStore = create<{
|
11 |
prompt: string
|
12 |
font: FontName
|
13 |
preset: Preset
|
|
|
14 |
panels: string[]
|
15 |
captions: Record<string, string>
|
16 |
-
|
17 |
zoomLevel: number
|
18 |
page: HTMLDivElement
|
19 |
isGeneratingStory: boolean
|
@@ -24,7 +25,7 @@ export const useStore = create<{
|
|
24 |
setFont: (font: FontName) => void
|
25 |
setPreset: (preset: Preset) => void
|
26 |
setPanels: (panels: string[]) => void
|
27 |
-
|
28 |
setCaption: (panelId: number, caption: string) => void
|
29 |
setZoomLevel: (zoomLevel: number) => void
|
30 |
setPage: (page: HTMLDivElement) => void
|
@@ -36,9 +37,10 @@ export const useStore = create<{
|
|
36 |
prompt: "",
|
37 |
font: "actionman",
|
38 |
preset: getPreset("japanese_manga"),
|
|
|
39 |
panels: [],
|
40 |
captions: {},
|
41 |
-
|
42 |
zoomLevel: 60,
|
43 |
page: undefined as unknown as HTMLDivElement,
|
44 |
isGeneratingStory: false,
|
@@ -50,7 +52,7 @@ export const useStore = create<{
|
|
50 |
if (prompt === existingPrompt) { return }
|
51 |
set({
|
52 |
prompt,
|
53 |
-
|
54 |
panels: [],
|
55 |
captions: {},
|
56 |
})
|
@@ -60,7 +62,7 @@ export const useStore = create<{
|
|
60 |
if (font === existingFont) { return }
|
61 |
set({
|
62 |
font,
|
63 |
-
|
64 |
panels: [],
|
65 |
captions: {}
|
66 |
})
|
@@ -70,7 +72,17 @@ export const useStore = create<{
|
|
70 |
if (preset.label === existingPreset.label) { return }
|
71 |
set({
|
72 |
preset,
|
73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
74 |
panels: [],
|
75 |
captions: {}
|
76 |
})
|
@@ -84,7 +96,7 @@ export const useStore = create<{
|
|
84 |
}
|
85 |
})
|
86 |
},
|
87 |
-
|
88 |
setZoomLevel: (zoomLevel: number) => set({ zoomLevel }),
|
89 |
setPage: (page: HTMLDivElement) => {
|
90 |
if (!page) { return }
|
|
|
4 |
|
5 |
import { FontName } from "@/lib/fonts"
|
6 |
import { Preset, getPreset } from "@/app/engine/presets"
|
7 |
+
import { LayoutName, getRandomLayoutNames } from "../layouts"
|
8 |
import html2canvas from "html2canvas"
|
9 |
|
10 |
export const useStore = create<{
|
11 |
prompt: string
|
12 |
font: FontName
|
13 |
preset: Preset
|
14 |
+
nbFrames: number
|
15 |
panels: string[]
|
16 |
captions: Record<string, string>
|
17 |
+
layouts: LayoutName[]
|
18 |
zoomLevel: number
|
19 |
page: HTMLDivElement
|
20 |
isGeneratingStory: boolean
|
|
|
25 |
setFont: (font: FontName) => void
|
26 |
setPreset: (preset: Preset) => void
|
27 |
setPanels: (panels: string[]) => void
|
28 |
+
setLayouts: (layouts: LayoutName[]) => void
|
29 |
setCaption: (panelId: number, caption: string) => void
|
30 |
setZoomLevel: (zoomLevel: number) => void
|
31 |
setPage: (page: HTMLDivElement) => void
|
|
|
37 |
prompt: "",
|
38 |
font: "actionman",
|
39 |
preset: getPreset("japanese_manga"),
|
40 |
+
nbFrames: 1,
|
41 |
panels: [],
|
42 |
captions: {},
|
43 |
+
layouts: getRandomLayoutNames(),
|
44 |
zoomLevel: 60,
|
45 |
page: undefined as unknown as HTMLDivElement,
|
46 |
isGeneratingStory: false,
|
|
|
52 |
if (prompt === existingPrompt) { return }
|
53 |
set({
|
54 |
prompt,
|
55 |
+
layouts: getRandomLayoutNames(),
|
56 |
panels: [],
|
57 |
captions: {},
|
58 |
})
|
|
|
62 |
if (font === existingFont) { return }
|
63 |
set({
|
64 |
font,
|
65 |
+
layouts: getRandomLayoutNames(),
|
66 |
panels: [],
|
67 |
captions: {}
|
68 |
})
|
|
|
72 |
if (preset.label === existingPreset.label) { return }
|
73 |
set({
|
74 |
preset,
|
75 |
+
layouts: getRandomLayoutNames(),
|
76 |
+
panels: [],
|
77 |
+
captions: {}
|
78 |
+
})
|
79 |
+
},
|
80 |
+
setNbFrames: (nbFrames: number) => {
|
81 |
+
const existingNbFrames = get().nbFrames
|
82 |
+
if (nbFrames === existingNbFrames) { return }
|
83 |
+
set({
|
84 |
+
nbFrames,
|
85 |
+
layouts: getRandomLayoutNames(),
|
86 |
panels: [],
|
87 |
captions: {}
|
88 |
})
|
|
|
96 |
}
|
97 |
})
|
98 |
},
|
99 |
+
setLayouts: (layouts: LayoutName[]) => set({ layouts }),
|
100 |
setZoomLevel: (zoomLevel: number) => set({ zoomLevel }),
|
101 |
setPage: (page: HTMLDivElement) => {
|
102 |
if (!page) { return }
|
src/lib/cropImage.ts
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
async function cropImage(inputImage: string): Promise<{ croppedImage: string; x: number; y: number; width: number; height: number }> {
|
2 |
+
return new Promise((resolve, reject) => {
|
3 |
+
const img = new Image();
|
4 |
+
img.src = inputImage;
|
5 |
+
img.onload = () => {
|
6 |
+
const canvas = document.createElement('canvas');
|
7 |
+
const context = canvas.getContext('2d');
|
8 |
+
if (!context) {
|
9 |
+
reject("Context is null");
|
10 |
+
return;
|
11 |
+
}
|
12 |
+
canvas.width = img.width;
|
13 |
+
canvas.height = img.height;
|
14 |
+
context.drawImage(img, 0, 0, img.width, img.height);
|
15 |
+
const imageData = context.getImageData(0, 0, img.width, img.height);
|
16 |
+
const data = imageData.data;
|
17 |
+
let minX = img.width, minY = img.height, maxX = 0, maxY = 0;
|
18 |
+
|
19 |
+
for (let y = 0; y < img.height; y++) {
|
20 |
+
for (let x = 0; x < img.width; x++) {
|
21 |
+
const i = (y * 4) * img.width + x * 4;
|
22 |
+
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
23 |
+
if (avg < 255) {
|
24 |
+
minX = Math.min(minX, x);
|
25 |
+
minY = Math.min(minY, y);
|
26 |
+
maxX = Math.max(maxX, x);
|
27 |
+
maxY = Math.max(maxY, y);
|
28 |
+
}
|
29 |
+
}
|
30 |
+
}
|
31 |
+
|
32 |
+
const width = maxX - minX;
|
33 |
+
const height = maxY - minY;
|
34 |
+
const croppedCanvas = document.createElement('canvas');
|
35 |
+
croppedCanvas.width = width;
|
36 |
+
croppedCanvas.height = height;
|
37 |
+
const croppedCtx = croppedCanvas.getContext('2d');
|
38 |
+
if (!croppedCtx) {
|
39 |
+
reject("croppedCtx is null");
|
40 |
+
return;
|
41 |
+
}
|
42 |
+
croppedCtx.drawImage(canvas, minX, minY, width, height, 0, 0, width, height);
|
43 |
+
resolve({
|
44 |
+
croppedImage: croppedCanvas.toDataURL(),
|
45 |
+
x: minX,
|
46 |
+
y: minY,
|
47 |
+
width,
|
48 |
+
height
|
49 |
+
});
|
50 |
+
};
|
51 |
+
img.onerror = reject;
|
52 |
+
});
|
53 |
+
}
|
src/lib/loadImage.ts
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export async function loadImage(image: string): Promise<HTMLImageElement> {
|
2 |
+
const img = new Image();
|
3 |
+
img.src = image;
|
4 |
+
|
5 |
+
const imgOnLoad = () => {
|
6 |
+
return new Promise<HTMLImageElement>((resolve, reject) => {
|
7 |
+
img.onload = () => { resolve(img) };
|
8 |
+
img.onerror = (err) => { reject(err) };
|
9 |
+
})
|
10 |
+
};
|
11 |
+
|
12 |
+
const loadImg = await imgOnLoad();
|
13 |
+
return loadImg
|
14 |
+
}
|
src/lib/replaceNonWhiteWithTransparent.ts
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export function replaceNonWhiteWithTransparent(imageBase64: string): Promise<string> {
|
2 |
+
return new Promise((resolve, reject) => {
|
3 |
+
const img = new Image();
|
4 |
+
img.onload = () => {
|
5 |
+
const canvas = document.createElement('canvas');
|
6 |
+
const ctx = canvas.getContext('2d');
|
7 |
+
if (!ctx) {
|
8 |
+
reject('Unable to get canvas context');
|
9 |
+
return;
|
10 |
+
}
|
11 |
+
|
12 |
+
const ratio = window.devicePixelRatio || 1;
|
13 |
+
canvas.width = img.width * ratio;
|
14 |
+
canvas.height = img.height * ratio;
|
15 |
+
ctx.scale(ratio, ratio);
|
16 |
+
|
17 |
+
ctx.drawImage(img, 0, 0);
|
18 |
+
|
19 |
+
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
20 |
+
const data = imageData.data;
|
21 |
+
console.log("ok")
|
22 |
+
|
23 |
+
for (let i = 0; i < data.length; i += 4) {
|
24 |
+
if (data[i] === 255 && data[i + 1] === 255 && data[i + 2] === 255) {
|
25 |
+
// Change white (also shades of grays) pixels to black
|
26 |
+
data[i] = 0;
|
27 |
+
data[i + 1] = 0;
|
28 |
+
data[i + 2] = 0;
|
29 |
+
} else {
|
30 |
+
// Change all other pixels to transparent
|
31 |
+
data[i + 3] = 0;
|
32 |
+
}
|
33 |
+
}
|
34 |
+
|
35 |
+
ctx.putImageData(imageData, 0, 0);
|
36 |
+
|
37 |
+
resolve(canvas.toDataURL());
|
38 |
+
};
|
39 |
+
|
40 |
+
img.onerror = (err) => {
|
41 |
+
reject(err);
|
42 |
+
};
|
43 |
+
|
44 |
+
img.src = imageBase64;
|
45 |
+
});
|
46 |
+
}
|
src/lib/replaceWhiteWithTransparent.ts
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export function replaceWhiteWithTransparent(imageBase64: string): Promise<string> {
|
2 |
+
return new Promise((resolve, reject) => {
|
3 |
+
const img = new Image();
|
4 |
+
img.onload = () => {
|
5 |
+
const canvas = document.createElement('canvas');
|
6 |
+
canvas.width = img.width;
|
7 |
+
canvas.height = img.height;
|
8 |
+
|
9 |
+
const ctx = canvas.getContext('2d');
|
10 |
+
if (!ctx) {
|
11 |
+
reject('Unable to get canvas 2D context');
|
12 |
+
return;
|
13 |
+
}
|
14 |
+
|
15 |
+
ctx.drawImage(img, 0, 0);
|
16 |
+
|
17 |
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
18 |
+
const data = imageData.data;
|
19 |
+
|
20 |
+
for (let i = 0; i < data.length; i += 4) {
|
21 |
+
if (data[i] === 255 && data[i + 1] === 255 && data[i + 2] === 255) {
|
22 |
+
data[i + 3] = 0;
|
23 |
+
}
|
24 |
+
}
|
25 |
+
|
26 |
+
ctx.putImageData(imageData, 0, 0);
|
27 |
+
|
28 |
+
resolve(canvas.toDataURL());
|
29 |
+
};
|
30 |
+
|
31 |
+
img.onerror = (err) => {
|
32 |
+
reject(err);
|
33 |
+
};
|
34 |
+
|
35 |
+
img.src = imageBase64;
|
36 |
+
});
|
37 |
+
}
|
src/lib/writeIntoBubble.ts
ADDED
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/*
|
2 |
+
I have a PNG image which contains a colored shape (roughly in the shape of a speech bubble), surrounded by white
|
3 |
+
|
4 |
+
Please write a TypeScript function (it should work in the browser) to:
|
5 |
+
|
6 |
+
1. replace all the white pixels with a transparent PNG pixel
|
7 |
+
2. replace all the colored pixels with a white pixel
|
8 |
+
3. write some input text into the colored shape
|
9 |
+
4. Make sure line returns are handled
|
10 |
+
5. It should have some padding (eg. 20px)
|
11 |
+
6. use Comic Sans MS
|
12 |
+
|
13 |
+
You can use the canvas for your operation. The signature should be something like:
|
14 |
+
|
15 |
+
- Please adjust the font size, based on the available number of pixels inside the bubble, taking some margin into account.
|
16 |
+
- The text should not be below 8px
|
17 |
+
- If there is not enough room to display it without going outside the shape, then crop the text.
|
18 |
+
- in other words, NEVER write outside the shape!
|
19 |
+
|
20 |
+
The function should be something like:
|
21 |
+
|
22 |
+
writeIntoBubble(image: string, text: string): Promise<string>
|
23 |
+
*/
|
24 |
+
|
25 |
+
export async function writeIntoBubble(image: string, text: string): Promise<string> {
|
26 |
+
const padding = 20; // Pixels
|
27 |
+
return new Promise((resolve, reject) => {
|
28 |
+
const img = new Image();
|
29 |
+
img.onload = () => {
|
30 |
+
const physicalWidth = img.width;
|
31 |
+
const physicalHeight = img.height;
|
32 |
+
const canvas = document.createElement('canvas');
|
33 |
+
const ctx = canvas.getContext('2d');
|
34 |
+
if (!ctx) {
|
35 |
+
reject('Unable to get canvas context');
|
36 |
+
return;
|
37 |
+
}
|
38 |
+
canvas.width = physicalWidth;
|
39 |
+
canvas.height = physicalHeight;
|
40 |
+
ctx.drawImage(img, 0, 0, physicalWidth, physicalHeight);
|
41 |
+
|
42 |
+
const imageData = ctx.getImageData(0, 0, physicalWidth, physicalHeight);
|
43 |
+
const data = imageData.data;
|
44 |
+
|
45 |
+
let minX = physicalWidth, minY = physicalHeight, maxX = 0, maxY = 0;
|
46 |
+
|
47 |
+
for (let y = 0; y < physicalHeight; y++) {
|
48 |
+
for (let x = 0; x < physicalWidth; x++) {
|
49 |
+
const i = (y * physicalWidth + x) * 4;
|
50 |
+
if (data[i] !== 255 || data[i + 1] !== 255 || data[i + 2] !== 255) {
|
51 |
+
minX = Math.min(minX, x);
|
52 |
+
minY = Math.min(minY, y);
|
53 |
+
maxX = Math.max(maxX, x);
|
54 |
+
maxY = Math.max(maxY, y);
|
55 |
+
data[i] = data[i + 1] = data[i + 2] = 255;
|
56 |
+
data[i + 3] = 255;
|
57 |
+
} else {
|
58 |
+
data[i + 3] = 0;
|
59 |
+
}
|
60 |
+
}
|
61 |
+
}
|
62 |
+
|
63 |
+
ctx.putImageData(imageData, 0, 0);
|
64 |
+
|
65 |
+
ctx.save();
|
66 |
+
ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transforms to handle padding correctly
|
67 |
+
|
68 |
+
const textX = minX + padding;
|
69 |
+
const textY = minY + padding;
|
70 |
+
const textWidth = (maxX - minX) - 2 * padding;
|
71 |
+
const textHeight = (maxY - minY) - 2 * padding;
|
72 |
+
|
73 |
+
ctx.restore();
|
74 |
+
|
75 |
+
ctx.rect(textX, textY, textWidth, textHeight);
|
76 |
+
ctx.clip(); // Clip outside of the region
|
77 |
+
|
78 |
+
let fontSize = 20; // Start with a large size
|
79 |
+
let lines = [];
|
80 |
+
do {
|
81 |
+
ctx.font = `${fontSize}px Comic Sans MS`;
|
82 |
+
lines = wrapText(ctx, text, textWidth);
|
83 |
+
fontSize -= 2; // Reduce size and try again if text doesn't fit
|
84 |
+
} while(lines.length > textHeight / fontSize && fontSize > 8);
|
85 |
+
ctx.font = `${fontSize}px Comic Sans MS`;
|
86 |
+
|
87 |
+
lines.forEach((line, i) => ctx.fillText(line, textX, textY + padding + i * fontSize));
|
88 |
+
|
89 |
+
resolve(canvas.toDataURL());
|
90 |
+
};
|
91 |
+
img.onerror = reject;
|
92 |
+
img.src = image;
|
93 |
+
});
|
94 |
+
}
|
95 |
+
|
96 |
+
// Function to wrap text into lines that fit inside a specified width
|
97 |
+
function wrapText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
98 |
+
const words = text.split(' ');
|
99 |
+
const lines = [];
|
100 |
+
let line = '';
|
101 |
+
|
102 |
+
for (let n = 0; n < words.length; n++) {
|
103 |
+
const testLine = line + words[n] + ' ';
|
104 |
+
const metrics = context.measureText(testLine);
|
105 |
+
const testWidth = metrics.width;
|
106 |
+
if (testWidth > maxWidth && n > 0) {
|
107 |
+
lines.push(line);
|
108 |
+
line = words[n] + ' ';
|
109 |
+
} else {
|
110 |
+
line = testLine;
|
111 |
+
}
|
112 |
+
}
|
113 |
+
lines.push(line);
|
114 |
+
return lines;
|
115 |
+
}
|
src/lib/writeIntoBubbles.ts
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { loadImage } from "./loadImage"
|
2 |
+
import { writeIntoBubble } from "./writeIntoBubble"
|
3 |
+
|
4 |
+
export async function writeIntoBubbles(image: string, texts: string[]): Promise<string> {
|
5 |
+
const loadImg = await loadImage(image);
|
6 |
+
|
7 |
+
const canvas = document.createElement('canvas');
|
8 |
+
const context = canvas.getContext('2d');
|
9 |
+
|
10 |
+
canvas.width = loadImg.width;
|
11 |
+
canvas.height = loadImg.height;
|
12 |
+
context?.drawImage(loadImg, 0, 0, loadImg.width, loadImg.height);
|
13 |
+
|
14 |
+
const untouchedImageData = context?.getImageData(0, 0, loadImg.width, loadImg.height);
|
15 |
+
if (!untouchedImageData) {
|
16 |
+
throw new Error("untouchedImageData is invalid")
|
17 |
+
}
|
18 |
+
const colorSet = new Set<string>(); // This is the unique color container
|
19 |
+
|
20 |
+
for(let i = 0; i < untouchedImageData?.data.length; i += 4){
|
21 |
+
const r = untouchedImageData?.data[i];
|
22 |
+
const g = untouchedImageData?.data[i+1];
|
23 |
+
const b = untouchedImageData?.data[i+2];
|
24 |
+
const colorString = `rgb(${r},${g},${b})`;
|
25 |
+
|
26 |
+
if(!colorSet.has(colorString)){
|
27 |
+
colorSet.add(colorString);
|
28 |
+
var newCanvas = document.createElement('canvas');
|
29 |
+
newCanvas.width = loadImg.width;
|
30 |
+
newCanvas.height = loadImg.height;
|
31 |
+
|
32 |
+
var newContext = newCanvas.getContext('2d');
|
33 |
+
newContext?.drawImage(loadImg, 0, 0, loadImg.width, loadImg.height);
|
34 |
+
var newImageData = newContext?.getImageData(0, 0, loadImg.width, loadImg.height);
|
35 |
+
if (!newImageData) {
|
36 |
+
throw new Error("newImageData is invalid")
|
37 |
+
}
|
38 |
+
|
39 |
+
for(let j = 0; j < newImageData?.data.length; j += 4){
|
40 |
+
const _r = newImageData?.data[j];
|
41 |
+
const _g = newImageData?.data[j+1];
|
42 |
+
const _b = newImageData?.data[j+2];
|
43 |
+
const _colorString = `rgb(${_r},${_g},${_b})`;
|
44 |
+
|
45 |
+
if(_colorString !== colorString){
|
46 |
+
newImageData?.data.set([0,0,0,0], j);
|
47 |
+
}
|
48 |
+
}
|
49 |
+
|
50 |
+
newContext?.putImageData(newImageData as ImageData, 0, 0);
|
51 |
+
|
52 |
+
let imageBase64 = newCanvas.toDataURL();
|
53 |
+
|
54 |
+
if(texts.length > 0){
|
55 |
+
let text = texts.shift() as string;
|
56 |
+
if (imageBase64 != '') {
|
57 |
+
const processedBase64 = await writeIntoBubble(imageBase64, text);
|
58 |
+
const newImg = await loadImage(processedBase64);
|
59 |
+
context?.drawImage(newImg, 0, 0, loadImg.width, loadImg.height);
|
60 |
+
}
|
61 |
+
}
|
62 |
+
}
|
63 |
+
}
|
64 |
+
return canvas.toDataURL();
|
65 |
+
}
|