diff --git a/.env b/.env index 9656ef77e0692dd7ab24776b7abffc556d0689cd..6539cf6505dc3ca4fe7a4dfdb4d598766fe0434b 100644 --- a/.env +++ b/.env @@ -2,6 +2,11 @@ ADMIN_HUGGING_FACE_API_TOKEN="" ADMIN_HUGGING_FACE_USERNAME="" +AI_TUBE_ROBOT_API="https://jbilcke-hf-ai-tube-robot.hf.space" + +VIDEOCHAIN_API_URL="" +VIDEOCHAIN_API_TOKEN="" + # ----------- CENSORSHIP ------- ENABLE_CENSORSHIP= FINGERPRINT_KEY= diff --git a/declarations.d.ts b/declarations.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..87a5938cc917fe6813f778b8a64d22efb18760f9 --- /dev/null +++ b/declarations.d.ts @@ -0,0 +1 @@ +declare module 'markdown-yaml-metadata-parser'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ca150a8942c27efe99e47b47498b5789e4bb567a..9010f0a3472e8d0a61a84f26d53d54dc28d7d174 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,10 +40,10 @@ "eslint-config-next": "13.4.10", "hash-wasm": "^4.11.0", "lucide-react": "^0.260.0", + "markdown-yaml-metadata-parser": "^3.0.0", "next": "^14.0.3", "pick": "^0.0.1", "postcss": "8.4.26", - "pythonia": "^1.0.4", "qs": "^6.11.2", "react": "18.2.0", "react-circular-progressbar": "^2.1.0", @@ -64,7 +64,7 @@ "type-fest": "^4.8.2", "typescript": "5.1.6", "usehooks-ts": "^2.9.1", - "uuid": "^9.0.0", + "uuid": "^9.0.1", "zustand": "^4.4.1" }, "devDependencies": { @@ -95,9 +95,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz", - "integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", + "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2361,11 +2361,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/caller": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/caller/-/caller-1.1.0.tgz", - "integrity": "sha512-n+21IZC3j06YpCWaxmUy5AnVqhmCIM2bQtqQyy00HJlmStRt6kwDX5F9Z97pqwAB+G/tgSz6q/kUBbNyQzIubw==" - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3011,6 +3006,14 @@ "node": ">=8" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -3108,9 +3111,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.595", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.595.tgz", - "integrity": "sha512-+ozvXuamBhDOKvMNUQvecxfbyICmIAwS4GpLmR0bsiSBlGnLaOcs2Cj7J8XSbW+YEaN3Xl3ffgpm+srTUWFwFQ==" + "version": "1.4.596", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.596.tgz", + "integrity": "sha512-zW3zbZ40Icb2BCWjm47nxwcFGYlIgdXkAx85XDO7cyky9J4QQfq8t0W19/TLZqq3JPQXtlv8BPIGmfa9Jb4scg==" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -3624,6 +3627,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", @@ -4717,6 +4732,38 @@ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" }, + "node_modules/markdown-yaml-metadata-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/markdown-yaml-metadata-parser/-/markdown-yaml-metadata-parser-3.0.0.tgz", + "integrity": "sha512-gRxEfuGIpb9pS1nQyASx3+l99e1hyTaK/+zDuvGcZJvr+OlksZ5O+q7opPcQP25j/z7NoOYEp17Lxgq5Sn4vDg==", + "dependencies": { + "detect-newline": "^3.1.0", + "js-yaml": "^3.14.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/markdown-yaml-metadata-parser/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/markdown-yaml-metadata-parser/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5409,18 +5456,6 @@ "node": ">=6" } }, - "node_modules/pythonia": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/pythonia/-/pythonia-1.0.4.tgz", - "integrity": "sha512-YciqyN0ii93gmJ1S9GmB873tZPtk6TeF/35DWLHrTn+PxnHCPtaXyvjPucK8gLNgt7XSqawmNxdp6JNFjWQL4g==", - "dependencies": { - "caller": "^1.0.1", - "chalk": "^4.1.2" - }, - "peerDependencies": { - "ws": "^7.5.1" - } - }, "node_modules/qs": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", @@ -6027,6 +6062,11 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -6849,27 +6889,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 5ebf689735fbe9c9b5919a0953820095bfea49f6..a8e5ea41898e2afcf3d6900968603a67e50ad189 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,10 @@ "eslint-config-next": "13.4.10", "hash-wasm": "^4.11.0", "lucide-react": "^0.260.0", + "markdown-yaml-metadata-parser": "^3.0.0", "next": "^14.0.3", "pick": "^0.0.1", "postcss": "8.4.26", - "pythonia": "^1.0.4", "qs": "^6.11.2", "react": "18.2.0", "react-circular-progressbar": "^2.1.0", @@ -65,7 +65,7 @@ "type-fest": "^4.8.2", "typescript": "5.1.6", "usehooks-ts": "^2.9.1", - "uuid": "^9.0.0", + "uuid": "^9.0.1", "zustand": "^4.4.1" }, "devDependencies": { diff --git a/src/app/interface/channel-card/index.tsx b/src/app/interface/channel-card/index.tsx index 698a4803af0debc69d5b25d52e7ef19c8725a69f..c8425434d0aea828f4d20a4ef1844835ee29e2c8 100644 --- a/src/app/interface/channel-card/index.tsx +++ b/src/app/interface/channel-card/index.tsx @@ -3,9 +3,11 @@ import { ChannelInfo } from "@/types" export function ChannelCard({ channel, + onClick, className = "", }: { channel: ChannelInfo + onClick?: (channel: ChannelInfo) => void className?: string }) { @@ -13,10 +15,19 @@ export function ChannelCard({
+ )} + onClick={() => { + if (onClick) { + onClick(channel) + } + }} + >
-

{channel.label}

+

{channel.likes} likes

) diff --git a/src/app/interface/channel-list/index.tsx b/src/app/interface/channel-list/index.tsx index d95e945c3bc874881bb656f20bd9d38c6b01db49..6344a679bbd7527e1dbc5654102a958bbff5cd04 100644 --- a/src/app/interface/channel-list/index.tsx +++ b/src/app/interface/channel-list/index.tsx @@ -5,11 +5,14 @@ import { ChannelCard } from "../channel-card" export function ChannelList({ channels, + onSelect, layout = "flex", className = "", }: { channels: ChannelInfo[] + onSelect?: (channel: ChannelInfo) => void + /** * Layout mode * @@ -35,6 +38,7 @@ export function ChannelList({ ))} diff --git a/src/app/interface/left-menu/index.tsx b/src/app/interface/left-menu/index.tsx index bf1da3193eb1c5648c5bdad9fd9cdc31310bd934..f12ddad440824278a7aacb2a8a3d40c6d606ffc6 100644 --- a/src/app/interface/left-menu/index.tsx +++ b/src/app/interface/left-menu/index.tsx @@ -1,6 +1,8 @@ import { GrChannel } from "react-icons/gr" import { MdVideoLibrary } from "react-icons/md" import { RiHome8Line } from "react-icons/ri" +import { PiRobot } from "react-icons/pi" +import { CgProfile } from "react-icons/cg" import { useStore } from "@/app/state/useStore" import { cn } from "@/lib/utils" @@ -11,35 +13,54 @@ export function LeftMenu() { const setView = useStore(s => s.setView) return (
- } - selected={view === "home"} - onClick={() => setView("home")} - > - Home - - } - selected={view === "channels_public"} - onClick={() => setView("channels_public")} - > - Channels - - } - selected={ - view === "channels_admin" || - view === "channel_admin" - } - onClick={() => setView("channels_admin")} - > - My Content - +
+ } + selected={view === "home"} + onClick={() => setView("home")} + > + Discover + + } + selected={view === "public_channels"} + onClick={() => setView("public_channels")} + > + Channels + +
+
+ {/*} + selected={view === "user_videos"} + onClick={() => setView("user_videos")} + > + My Videos + + */} + } + selected={view === "user_channels"} + onClick={() => setView("user_channels")} + > + My Robots + + } + selected={view === "user_account"} + onClick={() => setView("user_account")} + > + Account + +
) } \ No newline at end of file diff --git a/src/app/interface/top-menu/index.tsx b/src/app/interface/top-menu/index.tsx index 7dd5fa8c2173888a9fe851e6bc2688bd4ed74243..50222f58860935ed60131cb101f978710b26a9ee 100644 --- a/src/app/interface/top-menu/index.tsx +++ b/src/app/interface/top-menu/index.tsx @@ -1,4 +1,4 @@ -import { VideoCategory, videoCategoriesWithLabels } from "@/app/state/categories" +import { videoCategoriesWithLabels } from "@/app/state/categories" import { useStore } from "@/app/state/useStore" import { cn } from "@/lib/utils" @@ -7,8 +7,8 @@ export function TopMenu() { const setDisplayMode = useStore(s => s.setDisplayMode) const currentChannel = useStore(s => s.currentChannel) const setCurrentChannel = useStore(s => s.setCurrentChannel) - const currentCategory = useStore(s => s.currentCategory) - const setCurrentCategory = useStore(s => s.setCurrentCategory) + const currentTag = useStore(s => s.currentTag) + const setCurrentTag = useStore(s => s.setCurrentTag) const currentVideos = useStore(s => s.currentVideos) const currentVideo = useStore(s => s.currentVideo) const setCurrentVideo = useStore(s => s.setCurrentVideo) @@ -17,26 +17,26 @@ export function TopMenu() {
-
- 🍿 - HugTube +
+ 🍿 + AI Tube
- Search bar goes here + [ Search bar goes here ]
  {/* unused for now */} @@ -55,13 +55,13 @@ export function TopMenu() { `rounded-lg px-3 py-1 h-8`, `cursor-pointer`, `transition-all duration-300 ease-in-out`, - currentCategory === key + currentTag === key ? `bg-neutral-100 text-neutral-800` : `bg-neutral-800 text-neutral-50/90 hover:bg-neutral-700 hover:text-neutral-50/90`, // `text-clip` )} onClick={() => { - setCurrentCategory(key as VideoCategory) + setCurrentTag(key) }} > s.view) @@ -22,15 +23,21 @@ export function Main() {
- {view === "home" && } - {view === "channels_admin" && } - {view === "channels_public" && } - {view === "channel_public" && } - {view === "channel_admin" && } - {view === "video_public" && } +
+ {view === "home" && } + {view === "public_video" && } + {view === "public_channels" && } + {view === "public_channel" && } + {view === "user_channels" && } + {/*view === "user_videos" && */} + {view === "user_channel" && } + {view === "user_account" && } + +
) diff --git a/src/app/server/actions/ai-tube-hf/README.md b/src/app/server/actions/ai-tube-hf/README.md new file mode 100644 index 0000000000000000000000000000000000000000..31a77129c51d8622f38339dcdb0376d05b01ff5d --- /dev/null +++ b/src/app/server/actions/ai-tube-hf/README.md @@ -0,0 +1,3 @@ +# server/actions/ai-tube-hf + +Utility functions to manipulate channels hosted as Hugging Face Datasets \ No newline at end of file diff --git a/src/app/server/actions/ai-tube-hf/getChannels.ts b/src/app/server/actions/ai-tube-hf/getChannels.ts new file mode 100644 index 0000000000000000000000000000000000000000..51bfb72531d4cd0becfd713aa74fc00111193223 --- /dev/null +++ b/src/app/server/actions/ai-tube-hf/getChannels.ts @@ -0,0 +1,125 @@ +"use server" + +import { Credentials, downloadFile, listDatasets, whoAmI } from "@/huggingface/hub/src" +import { parseDatasetReadme } from "@/app/server/actions/utils/parseDatasetReadme" +import { ChannelInfo } from "@/types" + +import { adminCredentials } from "../config" + +export async function getChannels(options: { + apiKey?: string + owner?: string +} = {}): Promise { + + let credentials: Credentials = adminCredentials + let owner = options?.owner + + if (options?.apiKey) { + try { + credentials = { accessToken: options.apiKey } + const { name: username } = await whoAmI({ credentials }) + if (!username) { + throw new Error(`couldn't get the username`) + } + // everything is in order, + owner = username + } catch (err) { + console.error(err) + return [] + } + } + + let channels: ChannelInfo[] = [] + + const prefix = "ai-tube-" + + let search = owner + ? { owner } // search channels of a specific user + : prefix // global search (note: might be costly?) + + // console.log("search:", search) + + for await (const { id, name, likes, updatedAt } of listDatasets({ + search, + credentials + })) { + + // TODO: need to handle better cases where the username is missing + + const chunks = name.split("/") + const [datasetUser, datasetName] = chunks.length === 2 + ? chunks + : [name, name] + + // console.log(`found a candidate dataset "${datasetName}" owned by @${datasetUser}`) + + if (!datasetName.startsWith(prefix)) { + continue + } + + // ignore the video index + if (datasetName === "ai-tube-index") { + continue + } + + const slug = datasetName.replaceAll(prefix, "") + + // console.log(`found an AI Tube channel: "${slug}"`) + + // TODO parse the README to get the proper label + let label = slug.replaceAll("-", " ") + + const thumbnail = "" + let prompt = "" + let description = "" + let tags: string[] = [] + + // console.log(`going to read datasets/${name}`) + try { + const response = await downloadFile({ + repo: `datasets/${name}`, + path: "README.md", + credentials + }) + const readme = await response?.text() + + const ParsedDatasetReadme = parseDatasetReadme(readme) + + // console.log("ParsedDatasetReadme: ", ParsedDatasetReadme) + + + prompt = ParsedDatasetReadme.prompt + label = ParsedDatasetReadme.pretty_name + description = ParsedDatasetReadme.description + + const prefix = "ai-tube:" + + tags = ParsedDatasetReadme.tags + .filter(tag => tag.startsWith(prefix)) // remove any tag not belonging to us + .map(tag => tag.replaceAll(prefix, "").trim()) // remove the prefix + .filter(tag => tag) // remove empty tags + + + } catch (err) { + console.log("failed to read the readme:", err) + } + + const channel: ChannelInfo = { + id, + datasetUser, + datasetName, + slug, + label, + description, + thumbnail, + prompt, + likes, + tags, + updatedAt: updatedAt.toISOString() + } + + channels.push(channel) + } + + return channels +} diff --git a/src/app/server/actions/ai-tube-hf/getIndex.ts b/src/app/server/actions/ai-tube-hf/getIndex.ts new file mode 100644 index 0000000000000000000000000000000000000000..26f45ddb7b294b80ccb65f15bb2259d95f1b5287 --- /dev/null +++ b/src/app/server/actions/ai-tube-hf/getIndex.ts @@ -0,0 +1,48 @@ +import { downloadFile } from "@/huggingface/hub/src" +import { VideoInfo, VideoStatus } from "@/types" + +import { adminCredentials, adminUsername } from "../config" + +export async function getIndex({ + status, + renewCache, +}: { + status: VideoStatus + + /** + * Renew the cache + * + * This is was the batch job daemon will use, as in normal time + * we will want to use the cache since the file might be large + * + * it is also possible that we decide to *never* renew the cache from a user's perspective, + * and only renew it manually when a video changes status + * + * that way user requests will always be snappy! + */ + renewCache?: boolean +}): Promise> { + + // grab the current video index + const response = await downloadFile({ + credentials: adminCredentials, + repo: `datasets/${adminUsername}/ai-tube-index`, + path: `${status}.json`, + + }) + + // attention, this list might grow, especially the "published" one + // published videos should be put in a big dataset folder of files + // named ".json" and ".mp4" like in VideoChain + const jsonResponse = await response?.json() + if ( + typeof jsonResponse === "undefined" && + typeof jsonResponse !== "object" && + Array.isArray(jsonResponse) || + jsonResponse === null) { + throw new Error("index is not an object, admin repair needed") + } + const videos = jsonResponse as Record + + return videos +} diff --git a/src/app/server/actions/ai-tube-hf/getVideoRequestsFromChannel.ts b/src/app/server/actions/ai-tube-hf/getVideoRequestsFromChannel.ts new file mode 100644 index 0000000000000000000000000000000000000000..732baba58e36f0a61dcbd5a685a76f809a5b91b5 --- /dev/null +++ b/src/app/server/actions/ai-tube-hf/getVideoRequestsFromChannel.ts @@ -0,0 +1,112 @@ +"use server" + +import { Credentials, downloadFile, listFiles, whoAmI } from "@/huggingface/hub/src" +import { parseDatasetReadme } from "@/app/server/actions/utils/parseDatasetReadme" +import { ChannelInfo, VideoRequest } from "@/types" + +import { adminCredentials } from "../config" + +/** + * Return all the videos requests created by a user on their channel + * + * @param options + * @returns + */ +export async function getVideoRequestsFromChannel(options: { + channel: ChannelInfo, + apiKey?: string, + renewCache?: boolean +}): Promise> { + + let credentials: Credentials = adminCredentials + + if (options?.apiKey) { + try { + credentials = { accessToken: options.apiKey } + const { name: username } = await whoAmI({ credentials }) + if (!username) { + throw new Error(`couldn't get the username`) + } + } catch (err) { + console.error(err) + return {} + } + } + + let videos: Record = {} + + const repo = `datasets/${options.channel.datasetUser}/${options.channel.datasetName}` + + console.log(`scanning ${repo}`) + + for await (const file of listFiles({ + repo, + // recursive: true, + // expand: true, + credentials, + requestInit: { + // cache invalidation should be called right after adding a new video + cache: options?.renewCache ? "no-cache" : "default", + next: { + revalidate: 10, // otherwise we only update very 10 seconds by default + // tags: [] // tags used for cache invalidation (ie. this is added to the cache key) + } + } + })) { + + // TODO we should add some safety mechanisms here: + // skip lists of files that are too long + // skip files that are too big + // skip files with file.security.safe !== true + + console.log("file.path:", file.path) + /// { type, oid, size, path } + if (file.path === "README.md") { + console.log("found the README") + // TODO: read this readme + } else if (file.path.startsWith("prompt_") && file.path.endsWith(".txt")) { + console.log("yes!!") + const fileWithoutSuffix = file.path.split(".txt").shift() || "" + const words = fileWithoutSuffix.split("_") + console.log("debug:", { path: file.path, fileWithoutSuffix, words }) + if (words.length !== 3) { + console.log("found an invalid prompt file format: " + file.path) + continue + } + const [_prefix, date, id] = words + console.log("found a prompt:", file.path) + + try { + const response = await downloadFile({ + repo, + path: file.path, + credentials + }) + const rawMarkdown = await response?.text() + + const parsedDatasetReadme = parseDatasetReadme(rawMarkdown) + console.log("prompt parsed markdown:", parsedDatasetReadme) + } catch (err) { + console.log("failed to parse the prompt file") + continue + } + const video: VideoRequest = { + id, + label: "", + description: "", + prompt: "", + thumbnailUrl: "", + + updatedAt: file.lastCommit?.date || "", + tags: [], // read them from the file? + channel: options.channel + } + + videos[id] = video + } else if (file.path.endsWith(".mp4")) { + console.log("found a video:", file.path) + } + } + + return videos +} diff --git a/src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts b/src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts new file mode 100644 index 0000000000000000000000000000000000000000..86b0fa992876ddfe23f55601239b3b9a441cea96 --- /dev/null +++ b/src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts @@ -0,0 +1,114 @@ +"use server" + +import { Blob } from "buffer" +import { v4 as uuidv4 } from "uuid" + +import { Credentials, uploadFile, whoAmI } from "@/huggingface/hub/src" +import { ChannelInfo, VideoInfo, VideoRequest } from "@/types" + +/** + * Save the video request to the user's own dataset + * + * @param param0 + * @returns + */ +export async function uploadVideoRequestToDataset({ + channel, + apiKey, + title, + description, + prompt, + tags, +}: { + channel: ChannelInfo + apiKey: string + title: string + description: string + prompt: string + tags: string[] +}): Promise<{ + videoRequest: VideoRequest + videoInfo: VideoInfo +}> { + if (!apiKey) { + throw new Error(`the apiKey is required`) + } + + let credentials: Credentials = { accessToken: apiKey } + + const { name: username } = await whoAmI({ credentials }) + if (!username) { + throw new Error(`couldn't get the username`) + } + + const date = new Date() + const dateSlug = date.toISOString().replace(/[^0-9]/gi, '').slice(0, 12); + + // there is a bug in the [^] maybe, because all characters are removed + // const nameSlug = title.replaceAll(/\S+/gi, "-").replaceAll(/[^A-Za-z0-9\-_]/gi, "") + // const fileName = `prompt-${dateSlug}-${nameSlug}.txt` + + const videoId = uuidv4() + + const fileName = `prompt_${dateSlug}_${videoId}.txt` + + // Convert string to a Buffer + const blob = new Blob([` +# Title +${title} + +# Description +${description} + +# Tags + +${tags.map(tag => `- ${tag}\n`)} + +# Prompt +${prompt} +`]); + + + await uploadFile({ + credentials, + repo: `datasets/${channel.datasetUser}/${channel.datasetName}`, + file: { + path: fileName, + content: blob as any, + }, + commitTitle: "Add new video prompt", + }) + + // TODO: now we ping the robot to come read our prompt + + const newVideoRequest: VideoRequest = { + id: videoId, + label: title, + description, + prompt, + thumbnailUrl: "", + updatedAt: new Date().toISOString(), + tags: [...channel.tags], + channel, + } + + const newVideo: VideoInfo = { + id: videoId, + status: "submitted", + label: title, + description,, + prompt, + thumbnailUrl: "", // will be generated in async + assetUrl: "", // will be generated in async + numberOfViews: 0, + numberOfLikes: 0, + updatedAt: new Date().toISOString(), + tags: [...channel.tags], + channel, + } + + return { + videoRequest: newVideoRequest, + videoInfo: newVideo + } +} diff --git a/src/app/server/actions/ai-tube-robot/README.md b/src/app/server/actions/ai-tube-robot/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6e04c7833ab0272a9c77a9a0ad361cf3ecba3a8a --- /dev/null +++ b/src/app/server/actions/ai-tube-robot/README.md @@ -0,0 +1,3 @@ +# server/actions/ai-tube-robot + +API client for the AI Tube Robot diff --git a/src/app/server/actions/ai-tube-robot/updateQueue.ts b/src/app/server/actions/ai-tube-robot/updateQueue.ts new file mode 100644 index 0000000000000000000000000000000000000000..c860f89c0398f56b87d41f1e1db25b5064117c36 --- /dev/null +++ b/src/app/server/actions/ai-tube-robot/updateQueue.ts @@ -0,0 +1,42 @@ +"use server" + +import { ChannelInfo, UpdateQueueResponse } from "@/types" + +import { aiTubeRobotApi } from "../config" + +export async function updateQueue({ + channel, + apiKey, +}: { + channel?: ChannelInfo + apiKey: string +}): Promise { + if (!apiKey) { + throw new Error(`the apiKey is required`) + } + + const res = await fetch(`${aiTubeRobotApi}/update-queue`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + // Authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify({ + apiKey, + channel + }), + cache: 'no-store', + // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache) + // next: { revalidate: 1 } + }) + + if (res.status !== 200) { + // This will activate the closest `error.js` Error Boundary + throw new Error('Failed to fetch data') + } + + const response = (await res.json()) as UpdateQueueResponse + // console.log("response:", response) + return response.nbUpdated +} \ No newline at end of file diff --git a/src/app/server/actions/api.ts b/src/app/server/actions/api.ts deleted file mode 100644 index c93b74ea158e1d2f30a691fb87cef39208d269e4..0000000000000000000000000000000000000000 --- a/src/app/server/actions/api.ts +++ /dev/null @@ -1,88 +0,0 @@ -"use server" - -import { Credentials, listDatasets, whoAmI } from "@/huggingface/hub/src" -import { ChannelInfo } from "@/types" - -const adminApiKey = `${process.env.ADMIN_HUGGING_FACE_API_TOKEN || ""}` -const adminUsername = `${process.env.ADMIN_HUGGING_FACE_USERNAME || ""}` - -const adminCredentials: Credentials = { accessToken: adminApiKey } - -export async function getChannels(options: { - apiKey?: string - owner?: string -} = {}): Promise { - - let credentials: Credentials = adminCredentials - let owner = options?.owner - - if (options?.apiKey) { - try { - credentials = { accessToken: options.apiKey } - const { name: username } = await whoAmI({ credentials }) - if (!username) { - throw new Error(`couldn't get the username`) - } - // everything is in order, - owner = username - } catch (err) { - console.error(err) - return [] - } - } - - let channels: ChannelInfo[] = [] - - const prefix = "ai-tube-" - - let search = owner - ? { owner } // search channels of a specific user - : prefix // global search (note: might be costly?) - - console.log("search:", search) - - for await (const { id, name, likes, updatedAt } of listDatasets({ - search, - credentials - })) { - - const chunks = name.split("/") - const [datasetUsername, datasetName] = chunks.length === 2 - ? chunks - : [name, name] - - // console.log(`found a candidate dataset "${datasetName}" owned by @${datasetUsername}`) - - if (!datasetName.startsWith(prefix)) { - continue - } - - const slug = datasetName.replaceAll(prefix, "") - - console.log(`found an AI Tube channel: "${slug}"`) - - // TODO parse the README to get the proper label - const label = slug.replaceAll("-", " ") - - - // TODO parse the README to get this - // we could also use the user's avatar by default - const thumbnail = "" - - const prompt = "" // TODO parse the README to get this - - const channel: ChannelInfo = { - id, - slug, - label, - thumbnail, - prompt, - likes, - // updatedAt - } - - channels.push(channel) - } - - return channels -} diff --git a/src/app/server/actions/config.ts b/src/app/server/actions/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..22aa111c82b2f5b65d2c6eba2211a48f7f94015c --- /dev/null +++ b/src/app/server/actions/config.ts @@ -0,0 +1,9 @@ + +import { Credentials } from "@/huggingface/hub/src" + +export const adminApiKey = `${process.env.ADMIN_HUGGING_FACE_API_TOKEN || ""}` +export const adminUsername = `${process.env.ADMIN_HUGGING_FACE_USERNAME || ""}` + +export const adminCredentials: Credentials = { accessToken: adminApiKey } + +export const aiTubeRobotApi = `${process.env.AI_TUBE_ROBOT_API || ""}` diff --git a/src/app/server/actions/datasets.ts b/src/app/server/actions/datasets.ts deleted file mode 100644 index 997e27252771b453ed54af65094ad7047cc02a4a..0000000000000000000000000000000000000000 --- a/src/app/server/actions/datasets.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ChannelInfo, FullVideoInfo } from "@/types" - -export async function getPublicChannels({ - userHuggingFaceApiToken -}: { - userHuggingFaceApiToken: string -}): Promise { - // search on Hugging Face for - // TODO: we should probably cache this, and use a fixed list - return [] -} - -export async function getPrivateChannels({ - userHuggingFaceApiToken -}: { - userHuggingFaceApiToken: string -}): Promise { - return [] -} - -export async function getPrivateChannelVideos({ - userHuggingFaceApiToken, - channel, -}: { - userHuggingFaceApiToken: string - channel: ChannelInfo -}): Promise { - // TODO: - // call the Hugging Face API to grab all the files in the dataset - // we only get the first 30, that's enough for our demo - return [] -} \ No newline at end of file diff --git a/src/app/server/actions/generateImage.ts b/src/app/server/actions/generation/generateImage.txt similarity index 100% rename from src/app/server/actions/generateImage.ts rename to src/app/server/actions/generation/generateImage.txt diff --git a/src/app/server/actions/generateStoryLines.txt b/src/app/server/actions/generation/generateStoryLines.txt similarity index 100% rename from src/app/server/actions/generateStoryLines.txt rename to src/app/server/actions/generation/generateStoryLines.txt diff --git a/src/app/server/actions/generation/videochain.ts b/src/app/server/actions/generation/videochain.ts new file mode 100644 index 0000000000000000000000000000000000000000..691219f58d1cad2050603f4ed382b8f81e78f0d7 --- /dev/null +++ b/src/app/server/actions/generation/videochain.ts @@ -0,0 +1,161 @@ + +// note: there is no / at the end in the variable +// so we have to add it ourselves if needed +const apiUrl = process.env.VIDEOCHAIN_API_URL + +export const GET = async (path: string = '', defaultValue: T): Promise => { + try { + const res = await fetch(`${apiUrl}/${path}`, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${process.env.SECRET_ACCESS_TOKEN}`, + }, + cache: 'no-store', + // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache) + // next: { revalidate: 1 } + }) + + // The return value is *not* serialized + // You can return Date, Map, Set, etc. + + // Recommendation: handle errors + if (res.status !== 200) { + // This will activate the closest `error.js` Error Boundary + throw new Error('Failed to fetch data') + } + + const data = await res.json() + + return ((data as T) || defaultValue) + } catch (err) { + console.error(err) + return defaultValue + } +} + + +export const DELETE = async (path: string = '', defaultValue: T): Promise => { + try { + const res = await fetch(`${apiUrl}/${path}`, { + method: "DELETE", + headers: { + Accept: "application/json", + Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`, + }, + cache: 'no-store', + // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache) + // next: { revalidate: 1 } + }) + + // The return value is *not* serialized + // You can return Date, Map, Set, etc. + + // Recommendation: handle errors + if (res.status !== 200) { + // This will activate the closest `error.js` Error Boundary + throw new Error('Failed to fetch data') + } + + const data = await res.json() + + return ((data as T) || defaultValue) + } catch (err) { + console.error(err) + return defaultValue + } +} + +export const POST = async (path: string = '', payload: S, defaultValue: T): Promise => { + try { + const res = await fetch(`${apiUrl}/${path}`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`, + }, + body: JSON.stringify(payload), + // cache: 'no-store', + // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache) + next: { revalidate: 1 } + }) + // The return value is *not* serialized + // You can return Date, Map, Set, etc. + + // Recommendation: handle errors + if (res.status !== 200) { + // This will activate the closest `error.js` Error Boundary + throw new Error('Failed to post data') + } + + const data = await res.json() + + return ((data as T) || defaultValue) + } catch (err) { + return defaultValue + } +} + + +export const PUT = async (path: string = '', payload: S, defaultValue: T): Promise => { + try { + const res = await fetch(`${apiUrl}/${path}`, { + method: "PUT", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`, + }, + body: JSON.stringify(payload), + // cache: 'no-store', + // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache) + next: { revalidate: 1 } + }) + // The return value is *not* serialized + // You can return Date, Map, Set, etc. + + // Recommendation: handle errors + if (res.status !== 200) { + // This will activate the closest `error.js` Error Boundary + throw new Error('Failed to post data') + } + + const data = await res.json() + + return ((data as T) || defaultValue) + } catch (err) { + return defaultValue + } +} + +export const PATCH = async (path: string = '', payload: S, defaultValue: T): Promise => { + try { + const res = await fetch(`${apiUrl}/${path}`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`, + }, + body: JSON.stringify(payload), + // cache: 'no-store', + // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache) + next: { revalidate: 1 } + }) + // The return value is *not* serialized + // You can return Date, Map, Set, etc. + + // Recommendation: handle errors + if (res.status !== 200) { + // This will activate the closest `error.js` Error Boundary + throw new Error('Failed to post data') + } + + const data = await res.json() + + return ((data as T) || defaultValue) + } catch (err) { + return defaultValue + } +} \ No newline at end of file diff --git a/src/app/server/actions/python-api.ts b/src/app/server/actions/python-api.ts deleted file mode 100644 index b867b6b9897651cd4565721d551d44d854f41932..0000000000000000000000000000000000000000 --- a/src/app/server/actions/python-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -"use server" - -import { python } from "pythonia" - -const apiKey = `${process.env.ADMIN_HUGGING_FACE_API_TOKEN || ""}` - -export async function listDatasetCommunityPosts(): Promise { - - const { HfApi } = await python("huggingface_hub") - - const hf = await HfApi({ - endpoint: "https://huggingface.co", - token: apiKey - }) - // TODO - - return [] as any[] -} - diff --git a/src/app/server/actions/submitVideoRequest.ts b/src/app/server/actions/submitVideoRequest.ts new file mode 100644 index 0000000000000000000000000000000000000000..0575b8d667ba6c2656193278f45dd58d2b39e4e3 --- /dev/null +++ b/src/app/server/actions/submitVideoRequest.ts @@ -0,0 +1,47 @@ +"use server" + +import { ChannelInfo, VideoInfo } from "@/types" + +import { uploadVideoRequestToDataset } from "./ai-tube-hf/uploadVideoRequestToDataset" +import { updateQueue } from "./ai-tube-robot/updateQueue" + +export async function submitVideoRequest({ + channel, + apiKey, + title, + description, + prompt, + tags, +}: { + channel: ChannelInfo + apiKey: string + title: string + description: string + prompt: string + tags: string[] +}): Promise { + if (!apiKey) { + throw new Error(`the apiKey is required`) + } + + const { videoRequest, videoInfo } = await uploadVideoRequestToDataset({ + channel, + apiKey, + title, + description, + prompt, + tags + }) + + try { + await updateQueue({ apiKey, channel }) + + return { + ...videoInfo, + status: "queued" + } + } catch (err) { + console.error(`failed to update the queue, but this can be done later :)`) + return videoInfo + } +} \ No newline at end of file diff --git a/src/app/server/actions/censorship.ts b/src/app/server/actions/utils/censorship.ts similarity index 100% rename from src/app/server/actions/censorship.ts rename to src/app/server/actions/utils/censorship.ts diff --git a/src/app/server/actions/utils/parseDatasetPrompt.ts b/src/app/server/actions/utils/parseDatasetPrompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..02460c0801f0c440c10958a42190e418c1bf9ce4 --- /dev/null +++ b/src/app/server/actions/utils/parseDatasetPrompt.ts @@ -0,0 +1,49 @@ + +import { ParsedDatasetPrompt } from "@/types" + +export function parseDatasetPrompt(markdown: string = ""): ParsedDatasetPrompt { + try { + const { title, description, prompt } = parseMarkdown(markdown) + + return { + title: typeof title === "string" && title ? title : "", + description: typeof description === "string" && description ? description : "", + prompt: typeof prompt === "string" && prompt ? prompt : "", + } + } catch (err) { + return { + title: "", + description: "", + prompt: "", + } + } +} + +/** + * Simple Markdown Parser to extract sections into a JSON object + * @param markdown A Markdown string containing Description and Prompt sections + * @returns A JSON object with { "description": "...", "prompt": "..." } + */ +function parseMarkdown(markdown: string): ParsedDatasetPrompt { + // Regular expression to find markdown sections based on the provided structure + const sectionRegex = /^## (.+?)\n\n([\s\S]+?)(?=\n## |$)/gm; + + let match; + const sections: { [key: string]: string } = {}; + + // Iterate over each section match to populate the sections object + while ((match = sectionRegex.exec(markdown))) { + const [, key, value] = match; + sections[key.toLowerCase()] = value.trim(); + } + + // Create the resulting JSON object with "description" and "prompt" keys + const result = { + title: sections['title'] || '', + description: sections['description'] || '', + // categories: sections['categories'] || '', + prompt: sections['prompt'] || '', + }; + + return result; +} \ No newline at end of file diff --git a/src/app/server/actions/utils/parseDatasetReadme.ts b/src/app/server/actions/utils/parseDatasetReadme.ts new file mode 100644 index 0000000000000000000000000000000000000000..d02e3c59c9d0bd86cd3c773ba63d939a3a371b7b --- /dev/null +++ b/src/app/server/actions/utils/parseDatasetReadme.ts @@ -0,0 +1,62 @@ + +import metadataParser from "markdown-yaml-metadata-parser" + +import { ParsedDatasetReadme, ParsedMetadataAndContent } from "@/types" + +export function parseDatasetReadme(markdown: string = ""): ParsedDatasetReadme { + try { + const { metadata, content } = metadataParser(markdown) as ParsedMetadataAndContent + + // console.log("DEBUG README:", { metadata, content }) + + const { description, prompt } = parseMarkdown(content) + + return { + license: typeof metadata?.license === "string" ? metadata.license : "", + pretty_name: typeof metadata?.pretty_name === "string" ? metadata.pretty_name : "", + tags: Array.isArray(metadata?.tags) ? metadata.tags : [], + description, + prompt, + } + } catch (err) { + return { + license: "", + pretty_name: "", + tags: [], // Hugging Face tags + description: "", + prompt: "", + } + } +} + +/** + * Simple Markdown Parser to extract sections into a JSON object + * @param markdown A Markdown string containing Description and Prompt sections + * @returns A JSON object with { "description": "...", "prompt": "..." } + */ +function parseMarkdown(markdown: string): { + description: string + prompt: string + // categories: string +} { + // Regular expression to find markdown sections based on the provided structure + const sectionRegex = /^## (.+?)\n\n([\s\S]+?)(?=\n## |$)/gm; + + let match; + const sections: { [key: string]: string } = {}; + + // Iterate over each section match to populate the sections object + while ((match = sectionRegex.exec(markdown))) { + const [, key, value] = match; + sections[key.toLowerCase()] = value.trim(); + } + + // Create the resulting JSON object with "description" and "prompt" keys + const result = { + description: sections['description'] || '', + // categories: sections['categories'] || '', + prompt: sections['prompt'] || '', + }; + + return result; +} \ No newline at end of file diff --git a/src/app/server/config.ts b/src/app/server/config.ts deleted file mode 100644 index 40bb1c1b11a36abeef494e9fb431c1a84e0256f6..0000000000000000000000000000000000000000 --- a/src/app/server/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import path from "node:path" - -// see the .env file fore more informations -export const storagePath = `${process.env.STORAGE_PATH || './sandbox'}` - -export const partiesDirFilePath = path.join(storagePath, "parties") diff --git a/src/app/state/categories.ts b/src/app/state/categories.ts index 07dec43795ac48c7ab5371b503382d2f88f4f1f1..9cb74f9f54c3b9020b2d970ae619c9176628a515 100644 --- a/src/app/state/categories.ts +++ b/src/app/state/categories.ts @@ -1,13 +1,18 @@ + + +// TODO: +// this is obsolete, we should search on the Hugging Face platform instead + export const videoCategoriesWithLabels = { // "random": "Random", // "lofi": "Lofi Hip-Hop", - "sports": "Sports", - "education": "Education", - "timetravel": "Time Travel", // vlogs etc - "gaming": "Gaming", - "trailers": "Trailers", - "aitubers": "AI tubers", - "ads": "100% Ads", + "Sports": "Sports", + "Education": "Education", + "Time Travel": "Time Travel", // vlogs etc + // "gaming": "Gaming", + // "trailers": "Trailers", + // "aitubers": "AI tubers", + // "ads": "100% Ads", } export type VideoCategory = keyof typeof videoCategoriesWithLabels diff --git a/src/app/state/useStore.ts b/src/app/state/useStore.ts index e4b2c1cef4c26d923ad9c3d6ef7cc8001fc44064..9f2cbbfcd0e64155e2c20268302884d46d33d70a 100644 --- a/src/app/state/useStore.ts +++ b/src/app/state/useStore.ts @@ -2,8 +2,7 @@ import { create } from "zustand" -import { VideoCategory } from "./categories" -import { ChannelInfo, FullVideoInfo, InterfaceDisplayMode, InterfaceView } from "@/types" +import { ChannelInfo, VideoInfo, InterfaceDisplayMode, InterfaceView } from "@/types" export const useStore = create<{ displayMode: InterfaceDisplayMode @@ -18,14 +17,17 @@ export const useStore = create<{ currentChannels: ChannelInfo[] setCurrentChannels: (currentChannels?: ChannelInfo[]) => void - currentCategory?: VideoCategory - setCurrentCategory: (currentCategory?: VideoCategory) => void + currentTag?: string + setCurrentTag: (currentTag?: string) => void - currentVideos: FullVideoInfo[] - setCurrentVideos: (currentVideos: FullVideoInfo[]) => void + currentVideos: VideoInfo[] + setCurrentVideos: (currentVideos: VideoInfo[]) => void - currentVideo?: FullVideoInfo - setCurrentVideo: (currentVideo?: FullVideoInfo) => void + currentVideo?: VideoInfo + setCurrentVideo: (currentVideo?: VideoInfo) => void + + // currentPrompts: VideoInfo[] + // setCurrentPrompts: (currentPrompts: VideoInfo[]) => void }>((set, get) => ({ displayMode: "desktop", setDisplayMode: (displayMode: InterfaceDisplayMode) => { @@ -50,18 +52,18 @@ export const useStore = create<{ set({ currentChannels: Array.isArray(currentChannels) ? currentChannels : [] }) }, - currentCategory: undefined, - setCurrentCategory: (currentCategory?: VideoCategory) => { - set({ currentCategory }) + currentTag: undefined, + setCurrentTag: (currentTag?: string) => { + set({ currentTag }) }, currentVideos: [], - setCurrentVideos: (currentVideos: FullVideoInfo[] = []) => { + setCurrentVideos: (currentVideos: VideoInfo[] = []) => { set({ currentVideos: Array.isArray(currentVideos) ? currentVideos : [] }) }, currentVideo: undefined, - setCurrentVideo: (currentVideo?: FullVideoInfo) => { set({ currentVideo }) }, + setCurrentVideo: (currentVideo?: VideoInfo) => { set({ currentVideo }) }, })) \ No newline at end of file diff --git a/src/app/views/channel-admin-view/index.tsx b/src/app/views/channel-admin-view/index.tsx deleted file mode 100644 index 841c0b687c48c8c0378cacda5e87c040c0383717..0000000000000000000000000000000000000000 --- a/src/app/views/channel-admin-view/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect } from "react" - -import { useStore } from "@/app/state/useStore" -import { cn } from "@/lib/utils" -import { FullVideoInfo } from "@/types" -import { VideoList } from "@/app/interface/video-list" - -export function ChannelAdminView() { - const displayMode = useStore(s => s.displayMode) - const setDisplayMode = useStore(s => s.setDisplayMode) - const currentChannel = useStore(s => s.currentChannel) - const setCurrentChannel = useStore(s => s.setCurrentChannel) - const currentCategory = useStore(s => s.currentCategory) - const setCurrentCategory = useStore(s => s.setCurrentCategory) - const currentVideos = useStore(s => s.currentVideos) - const setCurrentVideos = useStore(s => s.setCurrentVideos) - const currentVideo = useStore(s => s.currentVideo) - const setCurrentVideo = useStore(s => s.setCurrentVideo) - - useEffect(() => { - - // we use fake data for now - // this will be pulled from the Hugging Face API - const newVideos: FullVideoInfo[] = [ - { - id: "42", - label: "Test Julian", - thumbnailUrl: "", - assetUrl: "", - numberOfViews: 0, - createdAt: "2023-11-27", - categories: [], - channelId: "", - channel: { - id: "", - slug: "", - label: "Hugging Face", - thumbnail: "", - prompt: "", - likes: 0, - } - } - ] - setCurrentVideos(newVideos) - }, [currentCategory]) - - return ( -
- -
- ) -} \ No newline at end of file diff --git a/src/app/views/channel-public-view/index.tsx b/src/app/views/channel-public-view/index.tsx deleted file mode 100644 index 322f25fceed6ab857c53e4a268dcbd522fd49b84..0000000000000000000000000000000000000000 --- a/src/app/views/channel-public-view/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect } from "react" - -import { useStore } from "@/app/state/useStore" -import { cn } from "@/lib/utils" -import { FullVideoInfo } from "@/types" -import { VideoList } from "@/app/interface/video-list" - -export function ChannelPublicView() { - const displayMode = useStore(s => s.displayMode) - const setDisplayMode = useStore(s => s.setDisplayMode) - const currentChannel = useStore(s => s.currentChannel) - const setCurrentChannel = useStore(s => s.setCurrentChannel) - const currentCategory = useStore(s => s.currentCategory) - const setCurrentCategory = useStore(s => s.setCurrentCategory) - const currentVideos = useStore(s => s.currentVideos) - const setCurrentVideos = useStore(s => s.setCurrentVideos) - const currentVideo = useStore(s => s.currentVideo) - const setCurrentVideo = useStore(s => s.setCurrentVideo) - - useEffect(() => { - - // we use fake data for now - // this will be pulled from the Hugging Face API - const newVideos: FullVideoInfo[] = [ - { - id: "42", - label: "Test Julian", - thumbnailUrl: "", - assetUrl: "", - numberOfViews: 0, - createdAt: "2023-11-27", - categories: [], - channelId: "", - channel: { - id: "", - slug: "", - label: "Hugging Face", - thumbnail: "", - prompt: "", - likes: 0, - } - } - ] - setCurrentVideos(newVideos) - }, [currentCategory]) - - return ( -
- -
- ) -} \ No newline at end of file diff --git a/src/app/views/home-view/index.tsx b/src/app/views/home-view/index.tsx index e6563f597862ebd05e8eb86f09155aeac2d13fd5..dc334541942e6f86ae2e3c207936ea921f3c5a72 100644 --- a/src/app/views/home-view/index.tsx +++ b/src/app/views/home-view/index.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react" import { useStore } from "@/app/state/useStore" import { cn } from "@/lib/utils" -import { FullVideoInfo } from "@/types" +import { VideoInfo } from "@/types" export function HomeView() { const displayMode = useStore(s => s.displayMode) @@ -20,7 +20,7 @@ export function HomeView() { // we use fake data for now // this will be pulled from the Hugging Face API - const newCategoryVideos: FullVideoInfo[] = [ + const newCategoryVideos: VideoInfo[] = [ { id: "42", label: "Test Julian", diff --git a/src/app/views/public-channel-view/index.tsx b/src/app/views/public-channel-view/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..537e71e189316ff5c4710b039c300cf01c73123c --- /dev/null +++ b/src/app/views/public-channel-view/index.tsx @@ -0,0 +1,43 @@ +import { useEffect, useTransition } from "react" + +import { useStore } from "@/app/state/useStore" +import { cn } from "@/lib/utils" +import { VideoInfo } from "@/types" +import { VideoList } from "@/app/interface/video-list" +import { getChannelVideos } from "@/app/server/actions/api" +import { useLocalStorage } from "usehooks-ts" +import { localStorageKeys } from "@/app/state/locaStorageKeys" +import { defaultSettings } from "@/app/state/defaultSettings" + +export function PublicChannelView() { + const [_isPending, startTransition] = useTransition() + const currentChannel = useStore(s => s.currentChannel) + const currentVideos = useStore(s => s.currentVideos) + const setCurrentVideos = useStore(s => s.setCurrentVideos) + const setCurrentVideo = useStore(s => s.setCurrentVideo) + + useEffect(() => { + if (!currentChannel) { + return + } + + startTransition(async () => { + const videos = await getChannelVideos({ + channel: currentChannel, + }) + console.log("videos:", videos) + }) + + setCurrentVideos([]) + }, [currentChannel, currentChannel?.id]) + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/app/views/channels-public-view/index.tsx b/src/app/views/public-channels-view/index.tsx similarity index 96% rename from src/app/views/channels-public-view/index.tsx rename to src/app/views/public-channels-view/index.tsx index 74d969970eccbfcf02300771d1bcd45f4dd91aec..decd31b7cc47fe2cf5950eed6f0ed6b0c811f425 100644 --- a/src/app/views/channels-public-view/index.tsx +++ b/src/app/views/public-channels-view/index.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" import { getChannels } from "@/app/server/actions/api" import { ChannelList } from "@/app/interface/channel-list" -export function ChannelsPublicView() { +export function PublicChannelsView() { const [_isPending, startTransition] = useTransition() const currentChannels = useStore(s => s.currentChannels) diff --git a/src/app/views/video-public-view/index.tsx b/src/app/views/public-video-view/index.tsx similarity index 80% rename from src/app/views/video-public-view/index.tsx rename to src/app/views/public-video-view/index.tsx index 627166a146d3ee75d6bfb692e77d210f03c19458..a63701d3f2a25265b903570010473b943db7d266 100644 --- a/src/app/views/video-public-view/index.tsx +++ b/src/app/views/public-video-view/index.tsx @@ -3,13 +3,13 @@ import { useEffect } from "react" import { useStore } from "@/app/state/useStore" import { cn } from "@/lib/utils" -export function VideoPublicView() { +export function PublicVideoView() { const displayMode = useStore(s => s.displayMode) const setDisplayMode = useStore(s => s.setDisplayMode) const currentChannel = useStore(s => s.currentChannel) const setCurrentChannel = useStore(s => s.setCurrentChannel) - const currentCategory = useStore(s => s.currentCategory) - const setCurrentCategory = useStore(s => s.setCurrentCategory) + const currentTag = useStore(s => s.currentTag) + const setCurrentTag = useStore(s => s.setCurrentTag) const currentVideos = useStore(s => s.currentVideos) const currentVideo = useStore(s => s.currentVideo) const setCurrentVideo = useStore(s => s.setCurrentVideo) diff --git a/src/app/views/user-account-view/index.tsx b/src/app/views/user-account-view/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..881659800137c8c90c6afd1729836a6dab9bf9d6 --- /dev/null +++ b/src/app/views/user-account-view/index.tsx @@ -0,0 +1,43 @@ +"use client" + +import { useTransition } from "react" +import { useLocalStorage } from "usehooks-ts" + +import { cn } from "@/lib/utils" +import { Input } from "@/components/ui/input" +import { localStorageKeys } from "@/app/state/locaStorageKeys" +import { defaultSettings } from "@/app/state/defaultSettings" + +export function UserAccountView() { + const [huggingfaceApiKey, setHuggingfaceApiKey] = useLocalStorage( + localStorageKeys.huggingfaceApiKey, + defaultSettings.huggingfaceApiKey + ) + + return ( +
+
+
+ + { + setHuggingfaceApiKey(x.target.value) + }} + value={huggingfaceApiKey} + /> +
+

+ Note: your Hugging Face token must be a WRITE access token. +

+
+ {huggingfaceApiKey + ?

You are ready to go!

+ :

Please setup your accountabove to get started

} +
+ ) +} \ No newline at end of file diff --git a/src/app/views/user-channel-view/index.tsx b/src/app/views/user-channel-view/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..43d86635befd91aba564ed90122e0336345bb3bf --- /dev/null +++ b/src/app/views/user-channel-view/index.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState, useTransition } from "react" + +import { useStore } from "@/app/state/useStore" +import { cn } from "@/lib/utils" +import { VideoInfo } from "@/types" +import { VideoList } from "@/app/interface/video-list" +import { submitVideoRequest, getChannelVideos } from "@/app/server/actions/api" +import { useLocalStorage } from "usehooks-ts" +import { localStorageKeys } from "@/app/state/locaStorageKeys" +import { defaultSettings } from "@/app/state/defaultSettings" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Button } from "@/components/ui/button" + +export function UserChannelView() { + const [_isPending, startTransition] = useTransition() + const [huggingfaceApiKey, setHuggingfaceApiKey] = useLocalStorage( + localStorageKeys.huggingfaceApiKey, + defaultSettings.huggingfaceApiKey + ) + const [titleDraft, setTitleDraft] = useState("") + const [promptDraft, setPromptDraft] = useState("") + + const [isSubmitting, setIsSubmitting] = useState(false) + + const currentChannel = useStore(s => s.currentChannel) + const currentVideos = useStore(s => s.currentVideos) + const setCurrentVideos = useStore(s => s.setCurrentVideos) + const setCurrentVideo = useStore(s => s.setCurrentVideo) + + useEffect(() => { + if (!currentChannel) { + return + } + + startTransition(async () => { + const videos = await getChannelVideos({ + channel: currentChannel, + apiKey: huggingfaceApiKey, + }) + console.log("videos:", videos) + }) + + setCurrentVideos([]) + }, [huggingfaceApiKey, currentChannel, currentChannel?.id]) + + const handleSubmit = () => { + if (!currentChannel) { + return + } + if (!titleDraft || !promptDraft) { + console.log("missing title or prompt") + return + } + + setIsSubmitting(true) + + startTransition(async () => { + try { + const newVideo = await submitVideoRequest({ + channel: currentChannel, + apiKey: huggingfaceApiKey, + title: titleDraft, + prompt: promptDraft + }) + + // in case of success we update the frontend immediately + // with our draft video + setCurrentVideos([newVideo, ...currentVideos]) + setPromptDraft("") + setTitleDraft("") + + // also renew the cache on Next's side + await getChannelVideos({ + channel: currentChannel, + apiKey: huggingfaceApiKey, + renewCache: true, + }) + } catch (err) { + console.error(err) + } finally { + setIsSubmitting(false) + } + }) + } + + return ( +
+

Robot channel settings:

+

TODO

+ +

Schedule a new prompt:

+ +
+ +
+ { + setTitleDraft(x.target.value) + }} + value={titleDraft} + /> +

+ Title of the video, keep it short. +

+
+
+ +
+ +
+