Julian Bilcke
commited on
Commit
Β·
c1e4aec
1
Parent(s):
fecef05
work in progress
Browse files- README.md +2 -2
- package-lock.json +28 -0
- package.json +1 -0
- src/app/interface/generate/index.tsx +79 -52
- src/app/layout.tsx +2 -2
- src/app/server/actions/{generateStory.ts β generateStoryLines.ts} +10 -14
- src/lib/useAudio.ts +64 -38
- src/types.ts +5 -1
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: AI Bedtime Story
|
| 3 |
emoji: π
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: gray
|
|
@@ -8,6 +8,6 @@ pinned: true
|
|
| 8 |
app_port: 3000
|
| 9 |
---
|
| 10 |
|
| 11 |
-
# AI Bedtime Story
|
| 12 |
|
| 13 |
(To be continued)
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AI Bedtime Story ποΈ
|
| 3 |
emoji: π
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: gray
|
|
|
|
| 8 |
app_port: 3000
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# π AI Bedtime Story ποΈ
|
| 12 |
|
| 13 |
(To be continued)
|
package-lock.json
CHANGED
|
@@ -50,6 +50,7 @@
|
|
| 50 |
"react-virtualized-auto-sizer": "^1.0.20",
|
| 51 |
"replicate": "^0.17.0",
|
| 52 |
"sbd": "^1.0.19",
|
|
|
|
| 53 |
"sharp": "^0.32.5",
|
| 54 |
"styled-components": "^6.0.7",
|
| 55 |
"tailwind-merge": "^1.13.2",
|
|
@@ -1691,6 +1692,11 @@
|
|
| 1691 |
"tslib": "^2.4.0"
|
| 1692 |
}
|
| 1693 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1694 |
"node_modules/@tsconfig/node10": {
|
| 1695 |
"version": "1.0.9",
|
| 1696 |
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
|
|
@@ -2248,6 +2254,11 @@
|
|
| 2248 |
"readable-stream": "^3.4.0"
|
| 2249 |
}
|
| 2250 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2251 |
"node_modules/brace-expansion": {
|
| 2252 |
"version": "1.1.11",
|
| 2253 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
|
@@ -5815,6 +5826,15 @@
|
|
| 5815 |
"node": ">=10"
|
| 5816 |
}
|
| 5817 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5818 |
"node_modules/set-function-length": {
|
| 5819 |
"version": "1.1.1",
|
| 5820 |
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
|
|
@@ -6084,6 +6104,14 @@
|
|
| 6084 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 6085 |
}
|
| 6086 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6087 |
"node_modules/styled-components": {
|
| 6088 |
"version": "6.1.1",
|
| 6089 |
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.1.tgz",
|
|
|
|
| 50 |
"react-virtualized-auto-sizer": "^1.0.20",
|
| 51 |
"replicate": "^0.17.0",
|
| 52 |
"sbd": "^1.0.19",
|
| 53 |
+
"sentence-splitter": "^4.3.0",
|
| 54 |
"sharp": "^0.32.5",
|
| 55 |
"styled-components": "^6.0.7",
|
| 56 |
"tailwind-merge": "^1.13.2",
|
|
|
|
| 1692 |
"tslib": "^2.4.0"
|
| 1693 |
}
|
| 1694 |
},
|
| 1695 |
+
"node_modules/@textlint/ast-node-types": {
|
| 1696 |
+
"version": "13.4.0",
|
| 1697 |
+
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-13.4.0.tgz",
|
| 1698 |
+
"integrity": "sha512-roVeLjnf8UPntFICb1uEwE2dccC8V/T5N1x7eBxkT3VDmSQkyfIAuGtlpwyH0wNKEwJmjO/2gSm2fCjW5K/rbA=="
|
| 1699 |
+
},
|
| 1700 |
"node_modules/@tsconfig/node10": {
|
| 1701 |
"version": "1.0.9",
|
| 1702 |
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
|
|
|
|
| 2254 |
"readable-stream": "^3.4.0"
|
| 2255 |
}
|
| 2256 |
},
|
| 2257 |
+
"node_modules/boundary": {
|
| 2258 |
+
"version": "2.0.0",
|
| 2259 |
+
"resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz",
|
| 2260 |
+
"integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="
|
| 2261 |
+
},
|
| 2262 |
"node_modules/brace-expansion": {
|
| 2263 |
"version": "1.1.11",
|
| 2264 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
|
|
|
| 5826 |
"node": ">=10"
|
| 5827 |
}
|
| 5828 |
},
|
| 5829 |
+
"node_modules/sentence-splitter": {
|
| 5830 |
+
"version": "4.3.0",
|
| 5831 |
+
"resolved": "https://registry.npmjs.org/sentence-splitter/-/sentence-splitter-4.3.0.tgz",
|
| 5832 |
+
"integrity": "sha512-srJOMqv7JeEmsbVa/N64ULey2N6/OuZzeKWn2Zrj0DiTBlU930JGr/rKKlKQRigzXtLMOtl32/Gm5G3HW8/ULA==",
|
| 5833 |
+
"dependencies": {
|
| 5834 |
+
"@textlint/ast-node-types": "^13.2.0",
|
| 5835 |
+
"structured-source": "^4.0.0"
|
| 5836 |
+
}
|
| 5837 |
+
},
|
| 5838 |
"node_modules/set-function-length": {
|
| 5839 |
"version": "1.1.1",
|
| 5840 |
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
|
|
|
|
| 6104 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 6105 |
}
|
| 6106 |
},
|
| 6107 |
+
"node_modules/structured-source": {
|
| 6108 |
+
"version": "4.0.0",
|
| 6109 |
+
"resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz",
|
| 6110 |
+
"integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==",
|
| 6111 |
+
"dependencies": {
|
| 6112 |
+
"boundary": "^2.0.0"
|
| 6113 |
+
}
|
| 6114 |
+
},
|
| 6115 |
"node_modules/styled-components": {
|
| 6116 |
"version": "6.1.1",
|
| 6117 |
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.1.tgz",
|
package.json
CHANGED
|
@@ -51,6 +51,7 @@
|
|
| 51 |
"react-virtualized-auto-sizer": "^1.0.20",
|
| 52 |
"replicate": "^0.17.0",
|
| 53 |
"sbd": "^1.0.19",
|
|
|
|
| 54 |
"sharp": "^0.32.5",
|
| 55 |
"styled-components": "^6.0.7",
|
| 56 |
"tailwind-merge": "^1.13.2",
|
|
|
|
| 51 |
"react-virtualized-auto-sizer": "^1.0.20",
|
| 52 |
"replicate": "^0.17.0",
|
| 53 |
"sbd": "^1.0.19",
|
| 54 |
+
"sentence-splitter": "^4.3.0",
|
| 55 |
"sharp": "^0.32.5",
|
| 56 |
"styled-components": "^6.0.7",
|
| 57 |
"tailwind-merge": "^1.13.2",
|
src/app/interface/generate/index.tsx
CHANGED
|
@@ -3,21 +3,19 @@
|
|
| 3 |
import { useEffect, useRef, useState, useTransition } from "react"
|
| 4 |
import { useSpring, animated } from "@react-spring/web"
|
| 5 |
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
|
|
| 6 |
|
| 7 |
import { useToast } from "@/components/ui/use-toast"
|
| 8 |
import { cn } from "@/lib/utils"
|
| 9 |
import { headingFont } from "@/app/interface/fonts"
|
| 10 |
import { useCharacterLimit } from "@/lib/useCharacterLimit"
|
| 11 |
-
import {
|
| 12 |
-
import {
|
| 13 |
-
import { HotshotImageInferenceSize, Post, SDXLModel, Story, TTSVoice } from "@/types"
|
| 14 |
-
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
| 15 |
import { TooltipProvider } from "@radix-ui/react-tooltip"
|
| 16 |
-
|
| 17 |
import { useCountdown } from "@/lib/useCountdown"
|
|
|
|
| 18 |
|
| 19 |
import { Countdown } from "../countdown"
|
| 20 |
-
import { useAudio } from "@/lib/useAudio"
|
| 21 |
|
| 22 |
type Stage = "generate" | "finished"
|
| 23 |
|
|
@@ -38,32 +36,54 @@ export function Generate() {
|
|
| 38 |
const [runs, setRuns] = useState(0)
|
| 39 |
const runsRef = useRef(0)
|
| 40 |
|
| 41 |
-
const
|
| 42 |
-
const
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
const [stage, setStage] = useState<Stage>("generate")
|
| 46 |
|
| 47 |
const { toast } = useToast()
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
const [typedStoryText, setTypedStoryText] = useState("")
|
| 50 |
const [typedStoryCharacterIndex, setTypedStoryCharacterIndex] = useState(0)
|
| 51 |
|
| 52 |
-
const audio = useAudio()
|
| 53 |
-
|
| 54 |
useEffect(() => {
|
| 55 |
if (storyText && typedStoryCharacterIndex < storyText.length) {
|
| 56 |
setTimeout(() => {
|
| 57 |
setTypedStoryText(typedStoryText + story.text[typedStoryCharacterIndex])
|
| 58 |
setTypedStoryCharacterIndex(typedStoryCharacterIndex + 1)
|
|
|
|
| 59 |
}, 40)
|
| 60 |
}
|
| 61 |
}, [storyText, typedStoryCharacterIndex])
|
|
|
|
| 62 |
|
| 63 |
const { progressPercent, remainingTimeInSec } = useCountdown({
|
| 64 |
isActive: isLocked,
|
| 65 |
timerId: runs, // everytime we change this, the timer will reset
|
| 66 |
-
durationInSec: /*stage === "interpolate" ? 30 :*/
|
| 67 |
onEnd: () => {}
|
| 68 |
})
|
| 69 |
|
|
@@ -108,28 +128,17 @@ export function Generate() {
|
|
| 108 |
const search = current.toString()
|
| 109 |
router.push(`${pathname}${search ? `?${search}` : ""}`)
|
| 110 |
|
| 111 |
-
let story: Story = {
|
| 112 |
-
text: "",
|
| 113 |
-
audio: ""
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
const voice: TTSVoice = "CloΓ©e"
|
| 117 |
|
| 118 |
setRuns(runsRef.current + 1)
|
| 119 |
|
| 120 |
try {
|
| 121 |
// console.log("starting transition, calling generateAnimation")
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
console.log("generated story:", story)
|
| 125 |
-
|
| 126 |
-
if (!story) {
|
| 127 |
-
throw new Error("invalid story")
|
| 128 |
-
}
|
| 129 |
|
| 130 |
-
(
|
| 131 |
|
| 132 |
-
|
| 133 |
|
| 134 |
} catch (err) {
|
| 135 |
|
|
@@ -171,21 +180,28 @@ export function Generate() {
|
|
| 171 |
|
| 172 |
|
| 173 |
useEffect(() => {
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
}
|
|
|
|
| 184 |
|
| 185 |
return () => {
|
| 186 |
audio() // stop
|
| 187 |
}
|
| 188 |
-
}, [
|
| 189 |
|
| 190 |
return (
|
| 191 |
<div
|
|
@@ -212,9 +228,10 @@ export function Generate() {
|
|
| 212 |
<div
|
| 213 |
className={cn(
|
| 214 |
`flex flex-col`,
|
| 215 |
-
`flex-grow
|
| 216 |
-
`
|
| 217 |
-
`
|
|
|
|
| 218 |
`items-center`,
|
| 219 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
| 220 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
|
@@ -252,14 +269,14 @@ export function Generate() {
|
|
| 252 |
`w-full`,
|
| 253 |
`input input-bordered rounded-full`,
|
| 254 |
`transition-all duration-300 ease-in-out`,
|
|
|
|
| 255 |
`placeholder:text-gray-400`,
|
| 256 |
`disabled:bg-gray-500 disabled:text-yellow-300 disabled:border-transparent`,
|
| 257 |
isLocked
|
| 258 |
-
? `bg-
|
| 259 |
-
: `bg-white/10 text-yellow-400 selection:bg-yellow-200`,
|
| 260 |
`text-left`,
|
| 261 |
-
`text-
|
| 262 |
-
`selection:bg-yellow-200 selection:text-yellow-200`
|
| 263 |
)}
|
| 264 |
value={promptDraft}
|
| 265 |
onChange={e => setPromptDraft(e.target.value)}
|
|
@@ -276,7 +293,7 @@ export function Generate() {
|
|
| 276 |
`flex flew-row ml-[-64px] items-center`,
|
| 277 |
`transition-all duration-300 ease-in-out`,
|
| 278 |
`text-base`,
|
| 279 |
-
`bg-yellow-200`,
|
| 280 |
`rounded-full`,
|
| 281 |
`text-right`,
|
| 282 |
`p-1`,
|
|
@@ -289,7 +306,7 @@ export function Generate() {
|
|
| 289 |
<span>{nbCharsLimits}</span>
|
| 290 |
</div>
|
| 291 |
</div>
|
| 292 |
-
<div className="flex flex-row w-
|
| 293 |
<animated.button
|
| 294 |
style={{
|
| 295 |
textShadow: "0px 0px 1px #000000ab",
|
|
@@ -298,15 +315,16 @@ export function Generate() {
|
|
| 298 |
onMouseEnter={() => setOverSubmitButton(true)}
|
| 299 |
onMouseLeave={() => setOverSubmitButton(false)}
|
| 300 |
className={cn(
|
| 301 |
-
`px-
|
| 302 |
`rounded-full`,
|
| 303 |
`transition-all duration-300 ease-in-out`,
|
|
|
|
| 304 |
isLocked
|
| 305 |
-
? `bg-orange-
|
| 306 |
-
: `bg-yellow-
|
| 307 |
`text-center`,
|
| 308 |
`w-full`,
|
| 309 |
-
`text-2xl
|
| 310 |
`border`,
|
| 311 |
headingFont.className,
|
| 312 |
// `transition-all duration-300`,
|
|
@@ -342,7 +360,9 @@ export function Generate() {
|
|
| 342 |
`items-center`,
|
| 343 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
| 344 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
| 345 |
-
|
|
|
|
|
|
|
| 346 |
)}>
|
| 347 |
{assetUrl ? <div
|
| 348 |
className={cn(
|
|
@@ -366,16 +386,23 @@ export function Generate() {
|
|
| 366 |
`items-center justify-between`
|
| 367 |
)}>
|
| 368 |
<div className={cn(
|
| 369 |
-
`flex flex-
|
| 370 |
)}>
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
</div>
|
| 373 |
</div>
|
| 374 |
</div>
|
| 375 |
|
| 376 |
</div>
|
| 377 |
|
| 378 |
-
|
| 379 |
</TooltipProvider>
|
| 380 |
</div>
|
| 381 |
)
|
|
|
|
| 3 |
import { useEffect, useRef, useState, useTransition } from "react"
|
| 4 |
import { useSpring, animated } from "@react-spring/web"
|
| 5 |
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
| 6 |
+
import { split } from "sentence-splitter"
|
| 7 |
|
| 8 |
import { useToast } from "@/components/ui/use-toast"
|
| 9 |
import { cn } from "@/lib/utils"
|
| 10 |
import { headingFont } from "@/app/interface/fonts"
|
| 11 |
import { useCharacterLimit } from "@/lib/useCharacterLimit"
|
| 12 |
+
import { generateStoryLines } from "@/app/server/actions/generateStoryLines"
|
| 13 |
+
import { Story, StoryLine, TTSVoice } from "@/types"
|
|
|
|
|
|
|
| 14 |
import { TooltipProvider } from "@radix-ui/react-tooltip"
|
|
|
|
| 15 |
import { useCountdown } from "@/lib/useCountdown"
|
| 16 |
+
import { useAudio } from "@/lib/useAudio"
|
| 17 |
|
| 18 |
import { Countdown } from "../countdown"
|
|
|
|
| 19 |
|
| 20 |
type Stage = "generate" | "finished"
|
| 21 |
|
|
|
|
| 36 |
const [runs, setRuns] = useState(0)
|
| 37 |
const runsRef = useRef(0)
|
| 38 |
|
| 39 |
+
const currentLineIndexRef = useRef(0)
|
| 40 |
+
const [currentLineIndex, setCurrentLineIndex] = useState(0)
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
currentLineIndexRef.current = currentLineIndex
|
| 44 |
+
}, [currentLineIndex])
|
| 45 |
+
|
| 46 |
+
const [storyLines, setStoryLines] = useState<StoryLine[]>([])
|
| 47 |
+
|
| 48 |
+
// computing those is cheap
|
| 49 |
+
const wholeStory = storyLines.map(line => line.text).join("\n")
|
| 50 |
+
const currentLine = storyLines.at(currentLineIndex)
|
| 51 |
+
const currentLineText = currentLine?.text || ""
|
| 52 |
+
const currentLineAudio = currentLine?.audio || ""
|
| 53 |
+
|
| 54 |
+
// reset the whole player when story changes
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
setCurrentLineIndex(0)
|
| 57 |
+
}, [wholeStory])
|
| 58 |
|
| 59 |
const [stage, setStage] = useState<Stage>("generate")
|
| 60 |
|
| 61 |
const { toast } = useToast()
|
| 62 |
|
| 63 |
+
const audio = useAudio()
|
| 64 |
+
|
| 65 |
+
/*
|
| 66 |
+
// to simulate a "typing" effect
|
| 67 |
+
however.. we don't need this as we already have an audio player!
|
| 68 |
+
|
| 69 |
const [typedStoryText, setTypedStoryText] = useState("")
|
| 70 |
const [typedStoryCharacterIndex, setTypedStoryCharacterIndex] = useState(0)
|
| 71 |
|
|
|
|
|
|
|
| 72 |
useEffect(() => {
|
| 73 |
if (storyText && typedStoryCharacterIndex < storyText.length) {
|
| 74 |
setTimeout(() => {
|
| 75 |
setTypedStoryText(typedStoryText + story.text[typedStoryCharacterIndex])
|
| 76 |
setTypedStoryCharacterIndex(typedStoryCharacterIndex + 1)
|
| 77 |
+
console.log("boom")
|
| 78 |
}, 40)
|
| 79 |
}
|
| 80 |
}, [storyText, typedStoryCharacterIndex])
|
| 81 |
+
*/
|
| 82 |
|
| 83 |
const { progressPercent, remainingTimeInSec } = useCountdown({
|
| 84 |
isActive: isLocked,
|
| 85 |
timerId: runs, // everytime we change this, the timer will reset
|
| 86 |
+
durationInSec: /*stage === "interpolate" ? 30 :*/ 35, // it usually takes 40 seconds, but there might be lag
|
| 87 |
onEnd: () => {}
|
| 88 |
})
|
| 89 |
|
|
|
|
| 128 |
const search = current.toString()
|
| 129 |
router.push(`${pathname}${search ? `?${search}` : ""}`)
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
const voice: TTSVoice = "CloΓ©e"
|
| 132 |
|
| 133 |
setRuns(runsRef.current + 1)
|
| 134 |
|
| 135 |
try {
|
| 136 |
// console.log("starting transition, calling generateAnimation")
|
| 137 |
+
const newStoryLines = await generateStoryLines(promptDraft, voice)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
console.log(`generated ${newStoryLines.length} story lines`)
|
| 140 |
|
| 141 |
+
setStoryLines(newStoryLines)
|
| 142 |
|
| 143 |
} catch (err) {
|
| 144 |
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
useEffect(() => {
|
| 183 |
+
const fn = async () => {
|
| 184 |
+
if (!currentLineAudio) {
|
| 185 |
+
return
|
| 186 |
+
}
|
| 187 |
+
console.log("story audio changed!")
|
| 188 |
|
| 189 |
+
try {
|
| 190 |
+
console.log("playing audio!")
|
| 191 |
+
await audio(currentLineAudio) // play
|
| 192 |
+
console.log("audio has ended, I think? let's go next!")
|
| 193 |
+
setCurrentLineIndex(currentLineIndexRef.current += 1)
|
| 194 |
+
// TODO change the line
|
| 195 |
+
} catch (err) {
|
| 196 |
+
console.error(err)
|
| 197 |
+
}
|
| 198 |
}
|
| 199 |
+
fn()
|
| 200 |
|
| 201 |
return () => {
|
| 202 |
audio() // stop
|
| 203 |
}
|
| 204 |
+
}, [currentLineAudio])
|
| 205 |
|
| 206 |
return (
|
| 207 |
<div
|
|
|
|
| 228 |
<div
|
| 229 |
className={cn(
|
| 230 |
`flex flex-col`,
|
| 231 |
+
`flex-grow`,
|
| 232 |
+
// `rounded-2xl md:rounded-3xl`,
|
| 233 |
+
// `backdrop-blur-md bg-gray-800/30`,
|
| 234 |
+
// `border-2 border-white/10`,
|
| 235 |
`items-center`,
|
| 236 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
| 237 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
|
|
|
| 269 |
`w-full`,
|
| 270 |
`input input-bordered rounded-full`,
|
| 271 |
`transition-all duration-300 ease-in-out`,
|
| 272 |
+
`backdrop-blur-md `,
|
| 273 |
`placeholder:text-gray-400`,
|
| 274 |
`disabled:bg-gray-500 disabled:text-yellow-300 disabled:border-transparent`,
|
| 275 |
isLocked
|
| 276 |
+
? `bg-white/10 text-yellow-400/60 selection:bg-yellow-200/60 selection:text-yellow-200/60 border-transparent`
|
| 277 |
+
: `bg-white/10 text-yellow-400/100 selection:bg-yellow-200/100 selection:text-yellow-200/100`,
|
| 278 |
`text-left`,
|
| 279 |
+
`text-2xl leading-10 px-6 h-16 pt-1`,
|
|
|
|
| 280 |
)}
|
| 281 |
value={promptDraft}
|
| 282 |
onChange={e => setPromptDraft(e.target.value)}
|
|
|
|
| 293 |
`flex flew-row ml-[-64px] items-center`,
|
| 294 |
`transition-all duration-300 ease-in-out`,
|
| 295 |
`text-base`,
|
| 296 |
+
// `bg-yellow-200`,
|
| 297 |
`rounded-full`,
|
| 298 |
`text-right`,
|
| 299 |
`p-1`,
|
|
|
|
| 306 |
<span>{nbCharsLimits}</span>
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
+
<div className="flex flex-row w-44">
|
| 310 |
<animated.button
|
| 311 |
style={{
|
| 312 |
textShadow: "0px 0px 1px #000000ab",
|
|
|
|
| 315 |
onMouseEnter={() => setOverSubmitButton(true)}
|
| 316 |
onMouseLeave={() => setOverSubmitButton(false)}
|
| 317 |
className={cn(
|
| 318 |
+
`px-4 h-16`,
|
| 319 |
`rounded-full`,
|
| 320 |
`transition-all duration-300 ease-in-out`,
|
| 321 |
+
`backdrop-blur-sm`,
|
| 322 |
isLocked
|
| 323 |
+
? `bg-orange-200/50 text-sky-50/80 border-yellow-600/10`
|
| 324 |
+
: `bg-yellow-400/70 text-sky-50 border-yellow-800/20 hover:bg-yellow-400/80`,
|
| 325 |
`text-center`,
|
| 326 |
`w-full`,
|
| 327 |
+
`text-2xl `,
|
| 328 |
`border`,
|
| 329 |
headingFont.className,
|
| 330 |
// `transition-all duration-300`,
|
|
|
|
| 360 |
`items-center`,
|
| 361 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
| 362 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
| 363 |
+
storyLines.length
|
| 364 |
+
? 'scale-100'
|
| 365 |
+
: 'scale-0'
|
| 366 |
)}>
|
| 367 |
{assetUrl ? <div
|
| 368 |
className={cn(
|
|
|
|
| 386 |
`items-center justify-between`
|
| 387 |
)}>
|
| 388 |
<div className={cn(
|
| 389 |
+
`flex flex-col flex-grow w-full space-y-2 text-2xl text-blue-200/90`
|
| 390 |
)}>
|
| 391 |
+
{storyLines.map((line, i) =>
|
| 392 |
+
<div
|
| 393 |
+
key={`${line.text}_${i}`}
|
| 394 |
+
|
| 395 |
+
// TODO change a color if we have progressed at the current index (i)
|
| 396 |
+
className={cn()}
|
| 397 |
+
>{
|
| 398 |
+
line.text
|
| 399 |
+
}</div>)}
|
| 400 |
</div>
|
| 401 |
</div>
|
| 402 |
</div>
|
| 403 |
|
| 404 |
</div>
|
| 405 |
|
|
|
|
| 406 |
</TooltipProvider>
|
| 407 |
</div>
|
| 408 |
)
|
src/app/layout.tsx
CHANGED
|
@@ -8,8 +8,8 @@ import './globals.css'
|
|
| 8 |
const inter = Inter({ subsets: ['latin'] })
|
| 9 |
|
| 10 |
export const metadata: Metadata = {
|
| 11 |
-
title: 'AI Bedtime Story
|
| 12 |
-
description: 'AI Bedtime Story
|
| 13 |
}
|
| 14 |
|
| 15 |
export default function RootLayout({
|
|
|
|
| 8 |
const inter = Inter({ subsets: ['latin'] })
|
| 9 |
|
| 10 |
export const metadata: Metadata = {
|
| 11 |
+
title: 'π AI Bedtime Story ποΈ',
|
| 12 |
+
description: 'π AI Bedtime Story ποΈ',
|
| 13 |
}
|
| 14 |
|
| 15 |
export default function RootLayout({
|
src/app/server/actions/{generateStory.ts β generateStoryLines.ts}
RENAMED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"use server"
|
| 2 |
|
| 3 |
-
import { Story, TTSVoice } from "@/types"
|
| 4 |
|
| 5 |
const instance = `${process.env.AI_BEDTIME_STORY_API_GRADIO_URL || ""}`
|
| 6 |
const secretToken = `${process.env.AI_BEDTIME_STORY_API_SECRET_TOKEN || ""}`
|
| 7 |
|
| 8 |
-
export async function
|
| 9 |
if (!prompt?.length) {
|
| 10 |
throw new Error(`prompt is too short!`)
|
| 11 |
}
|
|
@@ -34,22 +34,18 @@ export async function generateStory(prompt: string, voice: TTSVoice): Promise<St
|
|
| 34 |
// next: { revalidate: 1 }
|
| 35 |
})
|
| 36 |
|
| 37 |
-
console.log("res:", res)
|
| 38 |
-
const rawJson = await res.json()
|
| 39 |
-
console.log("rawJson:", rawJson)
|
| 40 |
-
const data = rawJson.data as Story[]
|
| 41 |
-
console.log("data:", data)
|
| 42 |
-
|
| 43 |
-
const story = data?.[0] || { text: "", audio: "" }
|
| 44 |
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
if (res.status !== 200 || !story?.text || !story?.audio) {
|
| 49 |
|
| 50 |
-
|
| 51 |
throw new Error('Failed to fetch data')
|
| 52 |
}
|
| 53 |
|
| 54 |
-
return
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
|
|
|
| 1 |
"use server"
|
| 2 |
|
| 3 |
+
import { Story, StoryLine, TTSVoice } from "@/types"
|
| 4 |
|
| 5 |
const instance = `${process.env.AI_BEDTIME_STORY_API_GRADIO_URL || ""}`
|
| 6 |
const secretToken = `${process.env.AI_BEDTIME_STORY_API_SECRET_TOKEN || ""}`
|
| 7 |
|
| 8 |
+
export async function generateStoryLines(prompt: string, voice: TTSVoice): Promise<StoryLine[]> {
|
| 9 |
if (!prompt?.length) {
|
| 10 |
throw new Error(`prompt is too short!`)
|
| 11 |
}
|
|
|
|
| 34 |
// next: { revalidate: 1 }
|
| 35 |
})
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
const rawJson = await res.json()
|
| 39 |
+
const data = rawJson.data as StoryLine[][]
|
| 40 |
|
| 41 |
+
const stories = data?.[0] || []
|
|
|
|
| 42 |
|
| 43 |
+
if (res.status !== 200) {
|
| 44 |
throw new Error('Failed to fetch data')
|
| 45 |
}
|
| 46 |
|
| 47 |
+
return stories.map(line => ({
|
| 48 |
+
text: line.text.replaceAll(" .", ".").replaceAll(" ?", "?").replaceAll(" !", "!").trim(),
|
| 49 |
+
audio: line.audio
|
| 50 |
+
}))
|
| 51 |
}
|
src/lib/useAudio.ts
CHANGED
|
@@ -1,54 +1,80 @@
|
|
| 1 |
-
import { useCallback, useEffect, useRef } from
|
| 2 |
|
| 3 |
-
/**
|
| 4 |
-
* Custom React hook to play a Base64 WAV file.
|
| 5 |
-
*/
|
| 6 |
export function useAudio() {
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
if (audioRef.current) {
|
| 13 |
-
audioRef.current.pause();
|
| 14 |
-
audioRef.current.src = ''; // Release the object URL to avoid memory leaks
|
| 15 |
-
audioRef.current = null;
|
| 16 |
-
}
|
| 17 |
}, []);
|
| 18 |
|
| 19 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
const playAudio = useCallback(
|
| 21 |
-
(base64Data?: string) => {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
// Clean up any existing audio first
|
| 29 |
-
stopAndCleanupAudio();
|
| 30 |
-
|
| 31 |
-
// Create a new Audio object and start playing
|
| 32 |
-
audioRef.current = new Audio(base64wav);
|
| 33 |
-
audioRef.current.play().catch((e) => {
|
| 34 |
-
console.error('Failed to play the audio:', e);
|
| 35 |
-
});
|
| 36 |
-
} else {
|
| 37 |
-
// If no base64 data provided, stop the audio
|
| 38 |
-
stopAndCleanupAudio();
|
| 39 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
},
|
| 41 |
-
[
|
| 42 |
);
|
| 43 |
|
| 44 |
// Effect to handle cleanup on component unmount
|
| 45 |
useEffect(() => {
|
| 46 |
return () => {
|
| 47 |
-
|
| 48 |
};
|
| 49 |
-
}, [
|
| 50 |
|
| 51 |
// Return the playAudio function from the hook
|
| 52 |
return playAudio;
|
| 53 |
-
}
|
| 54 |
-
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef } from 'react';
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
export function useAudio() {
|
| 4 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
| 5 |
+
|
| 6 |
+
const stopAudio = useCallback(() => {
|
| 7 |
+
audioContextRef.current?.close();
|
| 8 |
+
audioContextRef.current = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}, []);
|
| 10 |
|
| 11 |
+
// Helper function to handle conversion from Base64 to an ArrayBuffer
|
| 12 |
+
async function base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
|
| 13 |
+
const response = await fetch(base64);
|
| 14 |
+
return response.arrayBuffer();
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
const playAudio = useCallback(
|
| 18 |
+
async (base64Data?: string) => {
|
| 19 |
+
stopAudio(); // Stop any playing audio first
|
| 20 |
+
|
| 21 |
+
// If no base64 data provided, we don't attempt to play any audio
|
| 22 |
+
if (!base64Data) {
|
| 23 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
+
|
| 26 |
+
// Initialize AudioContext
|
| 27 |
+
const audioContext = new AudioContext();
|
| 28 |
+
audioContextRef.current = audioContext;
|
| 29 |
+
|
| 30 |
+
// Format Base64 string if necessary and get ArrayBuffer
|
| 31 |
+
const formattedBase64 =
|
| 32 |
+
base64Data.startsWith('data:audio/wav') || base64Data.startsWith('data:audio/wav;base64,')
|
| 33 |
+
? base64Data
|
| 34 |
+
: `data:audio/wav;base64,${base64Data}`;
|
| 35 |
+
|
| 36 |
+
console.log(`formattedBase64: ${formattedBase64.slice(0, 50)} (len: ${formattedBase64.length})`);
|
| 37 |
+
|
| 38 |
+
const arrayBuffer = await base64ToArrayBuffer(formattedBase64);
|
| 39 |
+
|
| 40 |
+
return new Promise((resolve, reject) => {
|
| 41 |
+
// Decode the audio data and play
|
| 42 |
+
audioContext.decodeAudioData(arrayBuffer, (audioBuffer) => {
|
| 43 |
+
// Create a source node and gain node
|
| 44 |
+
const source = audioContext.createBufferSource();
|
| 45 |
+
const gainNode = audioContext.createGain();
|
| 46 |
+
|
| 47 |
+
// Set buffer and gain
|
| 48 |
+
source.buffer = audioBuffer;
|
| 49 |
+
gainNode.gain.value = 1.0;
|
| 50 |
+
|
| 51 |
+
// Connect nodes
|
| 52 |
+
source.connect(gainNode);
|
| 53 |
+
gainNode.connect(audioContext.destination);
|
| 54 |
+
|
| 55 |
+
// Start playback and handle finishing
|
| 56 |
+
source.start();
|
| 57 |
+
|
| 58 |
+
source.onended = () => {
|
| 59 |
+
stopAudio();
|
| 60 |
+
resolve(true);
|
| 61 |
+
};
|
| 62 |
+
}, (error) => {
|
| 63 |
+
console.error('Error decoding audio data:', error);
|
| 64 |
+
reject(error);
|
| 65 |
+
});
|
| 66 |
+
})
|
| 67 |
},
|
| 68 |
+
[stopAudio]
|
| 69 |
);
|
| 70 |
|
| 71 |
// Effect to handle cleanup on component unmount
|
| 72 |
useEffect(() => {
|
| 73 |
return () => {
|
| 74 |
+
stopAudio();
|
| 75 |
};
|
| 76 |
+
}, [stopAudio]);
|
| 77 |
|
| 78 |
// Return the playAudio function from the hook
|
| 79 |
return playAudio;
|
| 80 |
+
}
|
|
|
src/types.ts
CHANGED
|
@@ -197,7 +197,11 @@ export type QualityOption = {
|
|
| 197 |
label: string
|
| 198 |
}
|
| 199 |
|
| 200 |
-
export type
|
| 201 |
text: string
|
| 202 |
audio: string // in base64
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
}
|
|
|
|
| 197 |
label: string
|
| 198 |
}
|
| 199 |
|
| 200 |
+
export type StoryLine = {
|
| 201 |
text: string
|
| 202 |
audio: string // in base64
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
export type Story = {
|
| 206 |
+
lines: StoryLine[]
|
| 207 |
}
|