no message
This commit is contained in:
commit
0798ae03ee
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
build/*
|
||||||
|
|
||||||
|
upload/*
|
||||||
|
uploads/*
|
||||||
|
|
||||||
|
db.sqlite
|
||||||
|
db.sqlite-shm
|
||||||
|
db.sqlite-wal
|
||||||
|
|
||||||
|
web/*
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
225
NOTICES.txt
Normal file
225
NOTICES.txt
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
Name: @aigne/core
|
||||||
|
License: Elastic-2.0
|
||||||
|
Repository: https://github.com/AIGNE-io/aigne-framework
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @aigne/openai
|
||||||
|
License: Elastic-2.0
|
||||||
|
Repository: https://github.com/AIGNE-io/aigne-framework
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @langchain/core
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/langchain-ai/langchainjs
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @langchain/openai
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/langchain-ai/langchainjs
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @rmp135/sql-ts
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/rmp135/sql-ts
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @types/cors
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @types/express-ws
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @types/express
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @types/jsonwebtoken
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @types/license-checker
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: @types/morgan
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: axios-retry
|
||||||
|
License: Apache-2.0
|
||||||
|
Repository: https://github.com/softonic/axios-retry
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: axios
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/axios/axios
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: better-sqlite3
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/WiseLibs/better-sqlite3
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: cors
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/expressjs/cors
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: dotenv
|
||||||
|
License: BSD-2-Clause
|
||||||
|
Repository: https://github.com/motdotla/dotenv
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: dotenv
|
||||||
|
License: BSD-2-Clause
|
||||||
|
Repository: https://github.com/motdotla/dotenv
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: electron-builder
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/electron-userland/electron-builder
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: electron
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/electron/electron
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: electronmon
|
||||||
|
License: ISC
|
||||||
|
Repository: https://github.com/catdad/electronmon
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: express-ws
|
||||||
|
License: BSD-2-Clause
|
||||||
|
Repository: https://github.com/HenningM/express-ws
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: express
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/expressjs/express
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: fast-glob
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/mrmlnc/fast-glob
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: form-data
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/form-data/form-data
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: is-path-inside
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/sindresorhus/is-path-inside
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: js-md5
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/emn178/js-md5
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: jsonwebtoken
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/auth0/node-jsonwebtoken
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: knex
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/knex/knex
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: langchain
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/langchain-ai/langchainjs
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: license-checker
|
||||||
|
License: BSD-3-Clause
|
||||||
|
Repository: https://github.com/davglass/license-checker
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: morgan
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/expressjs/morgan
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: nodemon
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/remy/nodemon
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: sharp
|
||||||
|
License: Apache-2.0
|
||||||
|
Repository: https://github.com/lovell/sharp
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: sqlite3
|
||||||
|
License: BSD-3-Clause
|
||||||
|
Repository: https://github.com/TryGhost/node-sqlite3
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: tsx
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/privatenumber/tsx
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: typescript
|
||||||
|
License: Apache-2.0
|
||||||
|
Repository: https://github.com/microsoft/TypeScript
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: zod
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/colinhacks/zod
|
||||||
|
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Name: zod
|
||||||
|
License: MIT
|
||||||
|
Repository: https://github.com/colinhacks/zod
|
||||||
232
README.md
Normal file
232
README.md
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<p align="center">
|
||||||
|
<strong>中文</strong> |
|
||||||
|
<a href="./docs/README.en.md">English</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="./docs/logo.png" alt="Toonflow Logo" height="120"/>
|
||||||
|
|
||||||
|
# Toonflow
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<b>
|
||||||
|
AI短剧工厂
|
||||||
|
<br />
|
||||||
|
动动手指,小说秒变剧集!
|
||||||
|
<br />
|
||||||
|
AI剧本 × AI影像 × 极速生成 🔥
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/HBAI-Ltd/Toonflow-app/stargazers">
|
||||||
|
<img src="https://img.shields.io/github/stars/HBAI-Ltd/Toonflow-app?style=for-the-badge&logo=github" alt="Stars Badge" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.gnu.org/licenses/agpl-3.0" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/License-AGPL-blue.svg?style=for-the-badge" alt="AGPL License Badge" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/HBAI-Ltd/Toonflow-app/releases">
|
||||||
|
<img alt="release" src="https://img.shields.io/github/v/release/HBAI-Ltd/Toonflow-app?style=for-the-badge" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> 🚀 **一站式短剧工程**:从文本到角色,从分镜到视频,0门槛全流程AI化,创作效率提升10倍+!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🌟 主要功能
|
||||||
|
|
||||||
|
Toonflow 是一款 AI 工具,能够利用 AI 技术将小说自动转化为剧本,并结合 AI 生成的图片和视频,实现高效的短剧创作。借助 Toonflow,可以轻松完成从文字到影像的全流程,让短剧制作变得更加智能与便捷。
|
||||||
|
|
||||||
|
- ✅ **角色生成**
|
||||||
|
自动分析原始小说文本,智能识别并生成角色设定,包括外貌、性格、身份等详细信息,为后续剧本与画面创作提供可靠基础。
|
||||||
|
- ✅ **剧本生成**
|
||||||
|
基于选定事件和章节,系统自动生成结构化剧本,涵盖对白、场景描述、剧情走向,实现从文学文本到影视剧本的高效转换。
|
||||||
|
- ✅ **分镜制作**
|
||||||
|
根据剧本内容,智能生成分镜提示词与画面设计,细化前中后景、角色动态、道具设定和场景布局,自动根据剧本生成分镜,为视频制作提供完整路线蓝图。
|
||||||
|
- ✅ **视频合成**
|
||||||
|
集成 AI 图像与视频技术,可使用 AI 生成视频片段。整合在线编辑,支持个性化调整输出,让影视创作高效协同、快捷落地。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📦 应用场景
|
||||||
|
|
||||||
|
- 短视频内容创作
|
||||||
|
- 小说影视化实验
|
||||||
|
- AI 文学 Adaptation 工具(改编工具)
|
||||||
|
- 剧本开发与快速原型
|
||||||
|
- 视频素材生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🚀 安装与使用指南
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
在安装和使用本软件之前,请准备以下内容:
|
||||||
|
|
||||||
|
- ✅ 大语言模型 AI 服务接口地址。
|
||||||
|
- ✅ Sora 或豆包视频服务接口地址
|
||||||
|
- ✅ Nano Banana Pro 图片生成模型服务接口
|
||||||
|
|
||||||
|
## 本机安装
|
||||||
|
|
||||||
|
### 1. 下载与安装
|
||||||
|
|
||||||
|
| 操作系统 | 下载链接 | 说明 |
|
||||||
|
| :------: | :------------------------------------------------------- | :----------------------- |
|
||||||
|
| Windows | [Release](https://github.com/HBAI-Ltd/Toonflow-app/releases) | 官方发布安装包,点击下载 |
|
||||||
|
| Linux | ⚙️ 敬请期待 | 即将发布,请持续关注 |
|
||||||
|
| macOS | ⚙️ 敬请期待 | 即将发布,请持续关注 |
|
||||||
|
|
||||||
|
> 注意:目前仅支持 Windows 版本,其他系统将陆续开放。
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
安装完成后,启动程序即可开始使用本服务。
|
||||||
|
|
||||||
|
## 云端部署
|
||||||
|
|
||||||
|
云端安装及部署教程正在整理中,敬请期待。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔧 开发流程指南
|
||||||
|
|
||||||
|
## 开发环境准备
|
||||||
|
|
||||||
|
- **Node.js**:版本要求 23.11.1 及以上
|
||||||
|
- **Yarn**:推荐作为项目包管理器
|
||||||
|
|
||||||
|
## 快速启动项目
|
||||||
|
|
||||||
|
1. **安装依赖**
|
||||||
|
|
||||||
|
请先在项目根目录下执行以下命令以安装依赖项:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **启动开发环境**
|
||||||
|
|
||||||
|
- 使用 Node.js 运行开发服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev #端口60000
|
||||||
|
```
|
||||||
|
|
||||||
|
- 使用 Bun 快速运行开发服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn bun:dev #端口60000
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **项目打包**
|
||||||
|
|
||||||
|
- 编译并生成 TypeScript 文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
- 打包为 Windows 平台可执行程序:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dist:win
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **代码质量检查**
|
||||||
|
|
||||||
|
- 进行全局语法和规范检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
📂 docs/ # 文档资源
|
||||||
|
📂 scripts/ # 构建脚本与静态资源
|
||||||
|
📂 src/
|
||||||
|
├─ 📂 agents/ # AI Agent 模块
|
||||||
|
├─ 📂 lib/ # 公共库(数据库初始化、响应格式)
|
||||||
|
├─ 📂 middleware/ # 中间件
|
||||||
|
├─ 📂 routes/ # 路由模块
|
||||||
|
│ ├─ 📂 assets/ # 素材管理
|
||||||
|
│ ├─ 📂 index/ # 首页
|
||||||
|
│ ├─ 📂 novel/ # 小说管理
|
||||||
|
│ ├─ 📂 other/ # 其他功能
|
||||||
|
│ ├─ 📂 outline/ # 大纲管理
|
||||||
|
│ ├─ 📂 project/ # 项目管理
|
||||||
|
│ ├─ 📂 prompt/ # 提示词管理
|
||||||
|
│ ├─ 📂 script/ # 剧本生成
|
||||||
|
│ ├─ 📂 setting/ # 系统设置
|
||||||
|
│ ├─ 📂 storyboard/ # 分镜管理
|
||||||
|
│ ├─ 📂 task/ # 任务管理
|
||||||
|
│ ├─ 📂 user/ # 用户管理
|
||||||
|
│ └─ 📂 video/ # 视频生成
|
||||||
|
├─ 📂 types/ # TypeScript 类型声明
|
||||||
|
├─ 📂 utils/ # 工具函数
|
||||||
|
├─ 📄 app.ts # 应用入口
|
||||||
|
├─ 📄 core.ts # 路由核心
|
||||||
|
├─ 📄 env.ts # 环境变量处理
|
||||||
|
├─ 📄 err.ts # 错误处理
|
||||||
|
├─ 📄 router.ts # 路由注册
|
||||||
|
└─ 📄 utils.ts # 通用工具
|
||||||
|
📂 uploads/ # 上传文件目录
|
||||||
|
📄 LICENSE # 许可证
|
||||||
|
📄 NOTICES.txt # 第三方依赖声明
|
||||||
|
📄 package.json # 项目配置
|
||||||
|
📄 README.md # 项目说明
|
||||||
|
📄 tsconfig.json # TypeScript 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📝 开发计划
|
||||||
|
|
||||||
|
我们正持续优化产品,以下为近期开发重点:
|
||||||
|
|
||||||
|
1. 核心功能升级
|
||||||
|
|
||||||
|
- `🧩 提示词润色生成 Agent` 基于 AI 智能润色视频提示词,自动拆解生成分镜脚本,支持多镜头智能融合与平滑过渡
|
||||||
|
- `📄 多格式文本支持` 扩展小说以外的剧本、漫画脚本、游戏对话文本等多种格式的智能解析
|
||||||
|
|
||||||
|
2. 生产流程优化
|
||||||
|
|
||||||
|
- `👗 角色服化道管理` 强化长篇内容中角色的服装、化妆、道具一致性,支持多剧集关联记忆和着装自动生成
|
||||||
|
- `📦 批量处理/任务队列` 支持多章节同时处理,后台任务管理,进度实时监控和中断恢复
|
||||||
|
|
||||||
|
3. 视觉生成增强
|
||||||
|
|
||||||
|
- `🎭 多风格模板库` 内置多种视觉风格包,支持一键风格转换和用户自定义风格保存
|
||||||
|
- `⏱️ 智能节奏分析/优化` 分析剧情情绪曲线,自动建议高潮点和节奏变化,优化分镜安排生产流程优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📜 许可证
|
||||||
|
|
||||||
|
Toonflow 基于 AGPL-3.0 协议开源发布,许可证详情:https://www.gnu.org/licenses/agpl-3.0.html
|
||||||
|
|
||||||
|
您可以在遵循 AGPL-3.0 相关条款与条件的情况下,将 Toonflow 用于包括商业目的在内的各类用途。
|
||||||
|
|
||||||
|
如需获得免于 AGPL-3.0 限制的专有商业许可,请通过邮箱与我们联系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 💌 联系我们
|
||||||
|
|
||||||
|
📧 邮箱:[ltlctools@outlook.com](mailto:ltlctools@outlook.com?subject=Toonflow咨询)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⭐️ 星标历史
|
||||||
|
|
||||||
|
[](https://star-history.com/#HBAI-Ltd/Toonflow-app&Date)
|
||||||
|
|
||||||
|
# 第三方依赖清单
|
||||||
|
|
||||||
|
请查阅`NOTICES.txt`
|
||||||
232
docs/README.en.md
Normal file
232
docs/README.en.md
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="../README.md">中文</a> |
|
||||||
|
<strong>English</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="./logo.png" alt="Toonflow Logo" height="120"/>
|
||||||
|
|
||||||
|
# Toonflow
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<b>
|
||||||
|
AI Short Drama Factory
|
||||||
|
<br />
|
||||||
|
Turn novels into episodes with just a tap!
|
||||||
|
<br />
|
||||||
|
AI Script × AI Visuals × Rapid Generation 🔥
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/HBAI-Ltd/Toonflow-app/stargazers">
|
||||||
|
<img src="https://img.shields.io/github/stars/HBAI-Ltd/Toonflow-app?style=for-the-badge&logo=github" alt="Stars Badge" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.gnu.org/licenses/agpl-3.0" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/License-AGPL-blue.svg?style=for-the-badge" alt="AGPL License Badge" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/HBAI-Ltd/Toonflow-app/releases">
|
||||||
|
<img alt="release" src="https://img.shields.io/github/v/release/HBAI-Ltd/Toonflow-app?style=for-the-badge" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> 🚀 **One-stop Short Drama Production**: From text to characters, from storyboards to videos, zero-barrier full-process AI automation, boosting creative efficiency by 10x+!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🌟 Main Features
|
||||||
|
|
||||||
|
Toonflow is an AI tool that leverages AI technology to automatically convert novels into scripts, combined with AI-generated images and videos for efficient short drama creation. With Toonflow, you can easily complete the entire workflow from text to visuals, making short drama production smarter and more convenient.
|
||||||
|
|
||||||
|
- ✅ **Character Generation**
|
||||||
|
Automatically analyzes original novel text, intelligently identifies and generates character settings including appearance, personality, identity, and other detailed information, providing a reliable foundation for subsequent script and visual creation.
|
||||||
|
- ✅ **Script Generation**
|
||||||
|
Based on selected events and chapters, the system automatically generates structured scripts covering dialogue, scene descriptions, and plot progression, achieving efficient conversion from literary text to film scripts.
|
||||||
|
- ✅ **Storyboard Production**
|
||||||
|
Based on script content, intelligently generates storyboard prompts and visual designs, detailing foreground, midground, background, character dynamics, prop settings, and scene layouts. Automatically generates storyboards from scripts, providing a complete blueprint for video production.
|
||||||
|
- ✅ **Video Synthesis**
|
||||||
|
Integrates AI image and video technology, enabling AI-generated video clips. Incorporates online editing with support for personalized output adjustments, making film production efficient and streamlined.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📦 Application Scenarios
|
||||||
|
|
||||||
|
- Short video content creation
|
||||||
|
- Novel-to-film experimentation
|
||||||
|
- AI Literary Adaptation Tools
|
||||||
|
- Script development and rapid prototyping
|
||||||
|
- Video material generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🚀 Installation and Usage Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before installing and using this software, please prepare the following:
|
||||||
|
|
||||||
|
- ✅ Large Language Model AI service API endpoint.
|
||||||
|
- ✅ Sora or Doubao video service API endpoint
|
||||||
|
- ✅ Nano Banana Pro image generation model service API endpoint
|
||||||
|
|
||||||
|
## Local Installation
|
||||||
|
|
||||||
|
### 1. Download and Install
|
||||||
|
|
||||||
|
| Operating System | Download Link | Description |
|
||||||
|
| :--------------: | :------------------------------------------------------------ | :------------------------------ |
|
||||||
|
| Windows | [Release](https://github.com/HBAI-Ltd/Toonflow-app/releases) | Official release package, click to download |
|
||||||
|
| Linux | ⚙️ Coming Soon | Coming soon, stay tuned |
|
||||||
|
| macOS | ⚙️ Coming Soon | Coming soon, stay tuned |
|
||||||
|
|
||||||
|
> Note: Currently only Windows version is supported, other systems will be available soon.
|
||||||
|
|
||||||
|
### 2. Start Service
|
||||||
|
|
||||||
|
After installation, launch the program to start using the service.
|
||||||
|
|
||||||
|
## Cloud Deployment
|
||||||
|
|
||||||
|
Cloud installation and deployment tutorials are being prepared, stay tuned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔧 Development Process Guide
|
||||||
|
|
||||||
|
## Development Environment Setup
|
||||||
|
|
||||||
|
- **Node.js**: Version 23.11.1 or above required
|
||||||
|
- **Yarn**: Recommended as the project package manager
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Install Dependencies**
|
||||||
|
|
||||||
|
Please first run the following command in the project root directory to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start Development Environment**
|
||||||
|
|
||||||
|
- Run development service with Node.js:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev #port 60000
|
||||||
|
```
|
||||||
|
|
||||||
|
- Run development service quickly with Bun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn bun:dev #port 60000
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Project Build**
|
||||||
|
|
||||||
|
- Compile and generate TypeScript files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Package as Windows platform executable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dist:win
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Code Quality Check**
|
||||||
|
|
||||||
|
- Perform global syntax and standard checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
📂 docs/ # Documentation resources
|
||||||
|
📂 scripts/ # Build scripts and static resources
|
||||||
|
📂 src/
|
||||||
|
├─ 📂 agents/ # AI Agent modules
|
||||||
|
├─ 📂 lib/ # Common libraries (database initialization, response format)
|
||||||
|
├─ 📂 middleware/ # Middleware
|
||||||
|
├─ 📂 routes/ # Route modules
|
||||||
|
│ ├─ 📂 assets/ # Asset management
|
||||||
|
│ ├─ 📂 index/ # Homepage
|
||||||
|
│ ├─ 📂 novel/ # Novel management
|
||||||
|
│ ├─ 📂 other/ # Other features
|
||||||
|
│ ├─ 📂 outline/ # Outline management
|
||||||
|
│ ├─ 📂 project/ # Project management
|
||||||
|
│ ├─ 📂 prompt/ # Prompt management
|
||||||
|
│ ├─ 📂 script/ # Script generation
|
||||||
|
│ ├─ 📂 setting/ # System settings
|
||||||
|
│ ├─ 📂 storyboard/ # Storyboard management
|
||||||
|
│ ├─ 📂 task/ # Task management
|
||||||
|
│ ├─ 📂 user/ # User management
|
||||||
|
│ └─ 📂 video/ # Video generation
|
||||||
|
├─ 📂 types/ # TypeScript type declarations
|
||||||
|
├─ 📂 utils/ # Utility functions
|
||||||
|
├─ 📄 app.ts # Application entry
|
||||||
|
├─ 📄 core.ts # Route core
|
||||||
|
├─ 📄 env.ts # Environment variable handling
|
||||||
|
├─ 📄 err.ts # Error handling
|
||||||
|
├─ 📄 router.ts # Route registration
|
||||||
|
└─ 📄 utils.ts # General utilities
|
||||||
|
📂 uploads/ # Upload file directory
|
||||||
|
📄 LICENSE # License
|
||||||
|
📄 NOTICES.txt # Third-party dependency declarations
|
||||||
|
📄 package.json # Project configuration
|
||||||
|
📄 README.md # Project description
|
||||||
|
📄 tsconfig.json # TypeScript configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📝 Development Roadmap
|
||||||
|
|
||||||
|
We are continuously optimizing the product. Here are the recent development priorities:
|
||||||
|
|
||||||
|
1. Core Feature Upgrades
|
||||||
|
|
||||||
|
- `🧩 Prompt Enhancement Generation Agent` AI-powered intelligent video prompt enhancement, automatic storyboard script decomposition, supporting multi-shot intelligent fusion and smooth transitions
|
||||||
|
- `📄 Multi-format Text Support` Extending intelligent parsing beyond novels to scripts, comic scripts, game dialogue texts, and other formats
|
||||||
|
|
||||||
|
2. Production Workflow Optimization
|
||||||
|
|
||||||
|
- `👗 Character Costume and Props Management` Strengthen costume, makeup, and prop consistency for long-form content, supporting multi-episode associated memory and automatic outfit generation
|
||||||
|
- `📦 Batch Processing/Task Queue` Support multi-chapter simultaneous processing, background task management, real-time progress monitoring, and interruption recovery
|
||||||
|
|
||||||
|
3. Visual Generation Enhancement
|
||||||
|
|
||||||
|
- `🎭 Multi-style Template Library` Built-in multiple visual style packages, supporting one-click style conversion and user-defined style saving
|
||||||
|
- `⏱️ Intelligent Rhythm Analysis/Optimization` Analyze plot emotion curves, automatically suggest climax points and rhythm changes, optimize storyboard arrangement and production workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📜 License
|
||||||
|
|
||||||
|
Toonflow is open-sourced under the AGPL-3.0 license. License details: https://www.gnu.org/licenses/agpl-3.0.html
|
||||||
|
|
||||||
|
You may use Toonflow for various purposes including commercial use, in compliance with the terms and conditions of AGPL-3.0.
|
||||||
|
|
||||||
|
For proprietary commercial licenses exempt from AGPL-3.0 restrictions, please contact us via email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 💌 Contact Us
|
||||||
|
|
||||||
|
📧 Email: [ltlctools@outlook.com](mailto:ltlctools@outlook.com?subject=Toonflow Inquiry)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⭐️ Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#HBAI-Ltd/Toonflow-app&Date)
|
||||||
|
|
||||||
|
# Third-party Dependency List
|
||||||
|
|
||||||
|
Please refer to `NOTICES.txt`
|
||||||
BIN
docs/logo.png
Normal file
BIN
docs/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 705 KiB |
55
electron-builder.yml
Normal file
55
electron-builder.yml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
appId: net.toonflow.www
|
||||||
|
productName: ToonFlow
|
||||||
|
copyright: Copyright © 2026
|
||||||
|
|
||||||
|
directories:
|
||||||
|
output: dist
|
||||||
|
buildResources: build
|
||||||
|
|
||||||
|
files:
|
||||||
|
- build/**/*
|
||||||
|
- scripts/web/**/*
|
||||||
|
- package.json
|
||||||
|
- node_modules/**/*
|
||||||
|
- "!node_modules/**/*.{md,ts,map}"
|
||||||
|
- "!node_modules/**/LICENSE*"
|
||||||
|
- "!node_modules/**/{README,readme}*"
|
||||||
|
- "!**/*.d.ts"
|
||||||
|
- "!src/**/*"
|
||||||
|
- "!scripts/**/*.ts"
|
||||||
|
|
||||||
|
asar: true
|
||||||
|
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
|
- portable
|
||||||
|
icon: ./scripts/logo.ico
|
||||||
|
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||||
|
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
perMachine: true
|
||||||
|
shortcutName: ${productName}
|
||||||
|
artifactName: ${productName}-Setup-${version}.${ext}
|
||||||
|
installerIcon: './scripts/logo.ico'
|
||||||
|
uninstallerIcon: './scripts/logo.ico'
|
||||||
|
|
||||||
|
mac:
|
||||||
|
target:
|
||||||
|
- dmg
|
||||||
|
- zip
|
||||||
|
icon: ./scripts/logo.icns
|
||||||
|
category: public.app-category.developer-tools
|
||||||
|
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||||
|
|
||||||
|
linux:
|
||||||
|
target:
|
||||||
|
- AppImage
|
||||||
|
- deb
|
||||||
|
icon: ./scripts/logo.png
|
||||||
|
category: Development
|
||||||
|
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||||
|
|
||||||
|
publish: null
|
||||||
64
package.json
Normal file
64
package.json
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "toonflow-serve",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "ToonFlow Serve - Electron Application",
|
||||||
|
"main": "build/main.js",
|
||||||
|
"author": "ToonFlow Team",
|
||||||
|
"packageManager": "yarn@1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon --inspect --exec tsx src/app.ts",
|
||||||
|
"dev:win": "chcp 65001 && electronmon -r tsx scripts/main.ts",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"build": "tsx scripts/build.ts",
|
||||||
|
"pack": "electron-builder --dir",
|
||||||
|
"dist": "yarn build && electron-builder",
|
||||||
|
"dist:win": "yarn build && electron-builder --win",
|
||||||
|
"dist:mac": "yarn build && electron-builder --mac",
|
||||||
|
"dist:linux": "yarn build && electron-builder --linux",
|
||||||
|
"test": "node build/app.js",
|
||||||
|
"license": "bun run scripts/license.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aigne/core": "^1.72.0",
|
||||||
|
"@aigne/openai": "^0.16.16",
|
||||||
|
"@langchain/core": "^1.1.15",
|
||||||
|
"@langchain/openai": "^1.2.1",
|
||||||
|
"@rmp135/sql-ts": "^2.2.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"axios-retry": "^4.5.0",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-ws": "^5.0.2",
|
||||||
|
"fast-glob": "^3.3.3",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"is-path-inside": "^4.0.0",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"knex": "^3.1.0",
|
||||||
|
"langchain": "^1.2.10",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/express-ws": "^3.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/license-checker": "^25.0.6",
|
||||||
|
"@types/morgan": "^1.9.10",
|
||||||
|
"electron": "^40.0.0",
|
||||||
|
"electron-builder": "^26.4.0",
|
||||||
|
"electronmon": "^2.0.4",
|
||||||
|
"license-checker": "^25.0.1",
|
||||||
|
"nodemon": "^3.1.11",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
scripts/build.ts
Normal file
55
scripts/build.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import esbuild from "esbuild";
|
||||||
|
|
||||||
|
const external = ["electron", "sqlite3", "better-sqlite3", "mysql", "mysql2", "pg", "pg-query-stream", "oracledb", "tedious", "mssql"];
|
||||||
|
|
||||||
|
// 后端服务打包配置
|
||||||
|
const appBuildConfig: esbuild.BuildOptions = {
|
||||||
|
entryPoints: ["src/app.ts"],
|
||||||
|
bundle: true,
|
||||||
|
minify: false,
|
||||||
|
format: "cjs",
|
||||||
|
allowOverwrite: true,
|
||||||
|
outfile: `build/app.js`,
|
||||||
|
platform: "node",
|
||||||
|
target: "esnext",
|
||||||
|
tsconfig: "./tsconfig.json",
|
||||||
|
alias: {
|
||||||
|
"@": "./src",
|
||||||
|
},
|
||||||
|
sourcemap: false,
|
||||||
|
external,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Electron 主进程打包配置
|
||||||
|
const mainBuildConfig: esbuild.BuildOptions = {
|
||||||
|
entryPoints: ["scripts/main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
minify: false,
|
||||||
|
format: "cjs",
|
||||||
|
outfile: `build/main.js`,
|
||||||
|
allowOverwrite: true,
|
||||||
|
platform: "node",
|
||||||
|
target: "esnext",
|
||||||
|
tsconfig: "./tsconfig.json",
|
||||||
|
alias: {
|
||||||
|
"@": "./src",
|
||||||
|
},
|
||||||
|
sourcemap: false,
|
||||||
|
external,
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
console.log("🔨 开始构建...\n");
|
||||||
|
|
||||||
|
// 并行构建
|
||||||
|
await Promise.all([esbuild.build(appBuildConfig), esbuild.build(mainBuildConfig)]);
|
||||||
|
|
||||||
|
console.log("✅ 后端服务构建完成: build/app.js");
|
||||||
|
console.log("✅ Electron主进程构建完成: build/main.js");
|
||||||
|
console.log("\n🎉 所有构建任务完成!\n");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ 构建失败:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
72
scripts/license.ts
Normal file
72
scripts/license.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import checker from "license-checker";
|
||||||
|
|
||||||
|
const excludeNames = ["toonflow-serve"];
|
||||||
|
// const strictWhiteList = ["MIT", "BSD-2-Clause", "BSD-3-Clause", "BSD", "0BSD"];
|
||||||
|
const strictWhiteList: string[] = [];
|
||||||
|
|
||||||
|
// 检查是否在白名单协议
|
||||||
|
function isStrictWhiteLicense(license: string): boolean {
|
||||||
|
const normalized = license.replace(/[\(\)]/g, "").trim();
|
||||||
|
const parts = normalized.split(/\s*(OR|AND|\/)\s*/i).map((part) => part.trim());
|
||||||
|
return parts.every((part) => strictWhiteList.some((wl) => part === wl || part.replace(/ with .*/i, "") === wl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 package.json 里的直接依赖
|
||||||
|
function getDirectDependencyNames(): string[] {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8"));
|
||||||
|
const deps = Object.keys(pkg.dependencies ?? {});
|
||||||
|
const devDeps = Object.keys(pkg.devDependencies ?? {});
|
||||||
|
return [...deps, ...devDeps];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行主逻辑
|
||||||
|
checker.init({ start: process.cwd() }, (err: Error, packages: Record<string, any>) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("license-checker 出错: ", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const directNames = getDirectDependencyNames();
|
||||||
|
|
||||||
|
interface PackageInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
licenses: string | string[];
|
||||||
|
repository: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needDeclare: PackageInfo[] = [];
|
||||||
|
for (const fullName in packages) {
|
||||||
|
// fullName 一般形如 [@scope/]pkg@version, 但 license-checker 会带路径,如 @scope/name@1.0.0@./node_modules/@scope/name
|
||||||
|
// 所以可以正则只保留 name@version 部分
|
||||||
|
// nameMatch[1] 为包名,nameMatch[2] 为版本
|
||||||
|
const nameMatch = fullName.match(/^((?:@[^\/]+\/)?[^@]+)@([^@]+)$/);
|
||||||
|
if (!nameMatch) continue;
|
||||||
|
const name = nameMatch[1];
|
||||||
|
// 仅关注直接依赖
|
||||||
|
if (!directNames.includes(name!)) continue;
|
||||||
|
|
||||||
|
const info = packages[fullName];
|
||||||
|
const licenseArr: string[] = Array.isArray(info.licenses) ? info.licenses : [info.licenses];
|
||||||
|
if (!licenseArr.every(isStrictWhiteLicense)) {
|
||||||
|
needDeclare.push({
|
||||||
|
name: name!,
|
||||||
|
version: info.version,
|
||||||
|
licenses: licenseArr,
|
||||||
|
repository: info.repository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排除名单过滤
|
||||||
|
const filteredDeclare = needDeclare.filter((pkg) => pkg.name && !excludeNames.some((exName) => pkg.name.startsWith(exName)));
|
||||||
|
const content = filteredDeclare
|
||||||
|
.map(
|
||||||
|
(pkg) =>
|
||||||
|
`Name: ${pkg.name}\nLicense: ${Array.isArray(pkg.licenses) ? pkg.licenses.join(", ") : pkg.licenses}\nRepository: ${pkg.repository ?? "N/A"}`
|
||||||
|
)
|
||||||
|
.join("\n\n-----------------------------\n\n");
|
||||||
|
fs.writeFileSync(path.resolve(process.cwd(), "NOTICES.txt"), content, "utf-8");
|
||||||
|
console.log("已生成依赖声明 NOTICES.txt");
|
||||||
|
});
|
||||||
BIN
scripts/logo.ico
Normal file
BIN
scripts/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
BIN
scripts/logo.png
Normal file
BIN
scripts/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 705 KiB |
34
scripts/main.ts
Normal file
34
scripts/main.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { app, BrowserWindow } from "electron";
|
||||||
|
import path from "path";
|
||||||
|
import startServe, { closeServe } from "src/app";
|
||||||
|
|
||||||
|
function createMainWindow(): void {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 900,
|
||||||
|
height: 600,
|
||||||
|
show: true,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
});
|
||||||
|
// 开发环境和生产环境使用不同的路径
|
||||||
|
const isDev = process.env.NODE_ENV === "dev" || !app.isPackaged;
|
||||||
|
const htmlPath = isDev
|
||||||
|
? path.join(process.cwd(), "scripts", "web", "index.html")
|
||||||
|
: path.join(app.getAppPath(), "scripts", "web", "index.html");
|
||||||
|
void win.loadFile(htmlPath);
|
||||||
|
}
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
createMainWindow();
|
||||||
|
await startServe();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", async (event) => {
|
||||||
|
await closeServe();
|
||||||
|
});
|
||||||
BIN
scripts/web/favicon.ico
Normal file
BIN
scripts/web/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
742
scripts/web/index.html
Normal file
742
scripts/web/index.html
Normal file
File diff suppressed because one or more lines are too long
36
src/agents/models.ts
Normal file
36
src/agents/models.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { ChatOpenAI, ChatOpenAIFields } from "@langchain/openai";
|
||||||
|
|
||||||
|
export const openAI = (config: ChatOpenAIFields = {}) => {
|
||||||
|
return new ChatOpenAI({
|
||||||
|
modelName: "gpt-4.1",
|
||||||
|
temperature: 1,
|
||||||
|
configuration: {
|
||||||
|
apiKey: process.env.AI_OPENAI_KEY,
|
||||||
|
baseURL: process.env.AI_OPENAI_URL,
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doubao = (config: ChatOpenAIFields = {}) => {
|
||||||
|
return new ChatOpenAI({
|
||||||
|
model: "doubao-seed-1-6-flash-250828",
|
||||||
|
temperature: 1,
|
||||||
|
configuration: {
|
||||||
|
apiKey: process.env.AI_TIKTOK_KEY,
|
||||||
|
baseURL: process.env.AI_TIKTOK_URL,
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deepseek = (config: ChatOpenAIFields = {}) =>
|
||||||
|
new ChatOpenAI({
|
||||||
|
model: "DeepSeek-V3.2",
|
||||||
|
temperature: 1,
|
||||||
|
configuration: {
|
||||||
|
apiKey: process.env.AI_DEEPSEEK_KEY,
|
||||||
|
baseURL: process.env.AI_DEEPSEEK_URL,
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
});
|
||||||
769
src/agents/outlineScript/index.ts
Normal file
769
src/agents/outlineScript/index.ts
Normal file
@ -0,0 +1,769 @@
|
|||||||
|
// @/agents/outlineScript.ts
|
||||||
|
import u from "@/utils";
|
||||||
|
import { createAgent } from "langchain";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { openAI } from "@/agents/models";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tool } from "@langchain/core/tools";
|
||||||
|
import type { DB } from "@/types/database";
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
type AgentType = "AI1" | "AI2" | "director";
|
||||||
|
type AssetType = "角色" | "道具" | "场景";
|
||||||
|
type RefreshEvent = "storyline" | "outline" | "assets";
|
||||||
|
|
||||||
|
interface AssetItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EpisodeData {
|
||||||
|
episodeIndex: number;
|
||||||
|
title: string;
|
||||||
|
chapterRange: number[];
|
||||||
|
scenes: AssetItem[]; // 按 outline 出场顺序排列
|
||||||
|
characters: AssetItem[]; // 按 outline 出场顺序排列
|
||||||
|
props: AssetItem[]; // 按 outline 出场顺序排列
|
||||||
|
coreConflict: string;
|
||||||
|
outline: string; // 最高优先级,剧本生成的唯一权威
|
||||||
|
openingHook: string; // outline 第一句话的视觉化,开篇第一个镜头
|
||||||
|
keyEvents: string[]; // 4个元素:[起, 承, 转, 合],严格按 outline 顺序
|
||||||
|
emotionalCurve: string; // 对应 keyEvents 各阶段
|
||||||
|
visualHighlights: string[]; // 按 outline 顺序排列的标志性镜头
|
||||||
|
endingHook: string; // outline 之后的悬念延伸
|
||||||
|
classicQuotes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Schema 定义 ====================
|
||||||
|
|
||||||
|
const sceneItemSchema = z.object({
|
||||||
|
name: z.string().describe("场景名称,如'五星酒店宴会厅'、'老旧出租屋'"),
|
||||||
|
description: z.string().describe("环境描写:空间结构、光线氛围、装饰陈设、环境细节"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const characterItemSchema = z.object({
|
||||||
|
name: z.string().describe("角色姓名(必须是具体人名,禁止'众人'、'群众'等集合描述)"),
|
||||||
|
description: z.string().describe("人设样貌:年龄体态、五官特征、发型妆容、服装配饰、气质神态"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const propItemSchema = z.object({
|
||||||
|
name: z.string().describe("道具名称"),
|
||||||
|
description: z.string().describe("样式描写:材质质感、颜色图案、形状尺寸、磨损痕迹、特殊标记"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const episodeSchema = z.object({
|
||||||
|
episodeIndex: z.number().describe("集数索引,从1开始递增"),
|
||||||
|
title: z.string().describe("8字内标题,疑问/感叹句,含情绪爆点"),
|
||||||
|
chapterRange: z.array(z.number()).describe("关联章节号数组"),
|
||||||
|
scenes: z.array(sceneItemSchema).describe("场景列表,按 outline 出场顺序排列"),
|
||||||
|
characters: z.array(characterItemSchema).describe("角色列表,按 outline 出场顺序排列,必须是独立个体"),
|
||||||
|
props: z.array(propItemSchema).describe("道具列表,按 outline 出场顺序排列,至少3个"),
|
||||||
|
coreConflict: z.string().describe("核心矛盾:A想要X vs B阻碍X"),
|
||||||
|
outline: z.string().describe("100-300字剧情主干,最高优先级,剧本生成的唯一权威,按时间顺序完整叙述"),
|
||||||
|
openingHook: z.string().describe("开场镜头:outline 第一句话的视觉化,必须作为剧本第一个镜头"),
|
||||||
|
keyEvents: z.array(z.string()).length(4).describe("4个元素的数组:[起, 承, 转, 合],严格按 outline 顺序从中提取"),
|
||||||
|
emotionalCurve: z.string().describe("情绪曲线,如:2(压抑)→5(反抗)→9(爆发)→3(余波),对应 keyEvents 各阶段"),
|
||||||
|
visualHighlights: z.array(z.string()).describe("3-5个标志性镜头,按 outline 叙事顺序排列"),
|
||||||
|
endingHook: z.string().describe("结尾悬念:outline 之后的延伸,勾引下集"),
|
||||||
|
classicQuotes: z.array(z.string()).describe("1-2句金句,每句≤15字,必须从原文提取"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 常量配置 ====================
|
||||||
|
|
||||||
|
// ==================== 主类 ====================
|
||||||
|
|
||||||
|
export default class OutlineScript {
|
||||||
|
private readonly projectId: number;
|
||||||
|
readonly emitter = new EventEmitter();
|
||||||
|
history: Array<[string, string]> = [];
|
||||||
|
novelChapters: DB["t_novel"][] = [];
|
||||||
|
|
||||||
|
modelName = "gpt-4.1";
|
||||||
|
apiKey = "";
|
||||||
|
baseURL = "";
|
||||||
|
|
||||||
|
constructor(projectId: number) {
|
||||||
|
this.projectId = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 公共方法 ====================
|
||||||
|
|
||||||
|
get events() {
|
||||||
|
return this.emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNovel(chapters: DB["t_novel"][]) {
|
||||||
|
this.novelChapters = chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 私有工具方法 ====================
|
||||||
|
|
||||||
|
private emit(event: string, data?: any) {
|
||||||
|
this.emitter.emit(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(type: RefreshEvent) {
|
||||||
|
this.emit("refresh", type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(action: string, detail?: string) {
|
||||||
|
const msg = detail ? `${action}: ${detail}` : action;
|
||||||
|
console.log(`\n[${new Date().toLocaleTimeString()}] ${msg}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeParseJson<T>(str: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private uniqueByName<T extends { name: string }>(items: T[]): T[] {
|
||||||
|
return Array.from(new Map(items.map((item) => [item.name, item])).values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据库操作 ====================
|
||||||
|
|
||||||
|
private async getProjectInfo(): Promise<any> {
|
||||||
|
return u.db("t_project").where({ id: this.projectId }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNovelInfo(asString = false): Promise<any> {
|
||||||
|
const info = await this.getProjectInfo();
|
||||||
|
if (!info) return asString ? "未查询到项目信息" : null;
|
||||||
|
|
||||||
|
if (asString) {
|
||||||
|
const fields = [
|
||||||
|
`小说名称: ${info.name}`,
|
||||||
|
`小说简介: ${info.intro}`,
|
||||||
|
`小说类型: ${info.type}`,
|
||||||
|
`目标短剧类型: ${info.artStyle}`,
|
||||||
|
`短剧画幅: ${info.videoRatio}`,
|
||||||
|
];
|
||||||
|
return fields.join("\n");
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 故事线操作 ====================
|
||||||
|
|
||||||
|
private async findStoryline() {
|
||||||
|
return u.db("t_storyline").where({ projectId: this.projectId }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertStorylineContent(content: string) {
|
||||||
|
const existing = await this.findStoryline();
|
||||||
|
if (existing) {
|
||||||
|
await u.db("t_storyline").where({ projectId: this.projectId }).update({ content });
|
||||||
|
} else {
|
||||||
|
await u.db("t_storyline").insert({ projectId: this.projectId, content });
|
||||||
|
}
|
||||||
|
this.refresh("storyline");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteStorylineContent() {
|
||||||
|
const deleted = await u.db("t_storyline").where({ projectId: this.projectId }).del();
|
||||||
|
this.refresh("storyline");
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 大纲操作 ====================
|
||||||
|
|
||||||
|
private async findOutlines() {
|
||||||
|
return u.db("t_outline").where({ projectId: this.projectId }).orderBy("episode", "asc");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOutlineById(id: number) {
|
||||||
|
return u.db("t_outline").where({ id, projectId: this.projectId }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMaxEpisode(): Promise<number> {
|
||||||
|
const result: any = await u.db("t_outline").where({ projectId: this.projectId }).max("episode as max").first();
|
||||||
|
return result?.max ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearOutlinesAndScripts() {
|
||||||
|
const outlines = await u.db("t_outline").select("id").where({ projectId: this.projectId });
|
||||||
|
if (outlines.length === 0) return 0;
|
||||||
|
|
||||||
|
const outlineIds = outlines.map((o) => o.id);
|
||||||
|
await u.db("t_script").whereIn("outlineId", outlineIds).del();
|
||||||
|
await u.db("t_outline").where({ projectId: this.projectId }).del();
|
||||||
|
|
||||||
|
return outlines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertOutlines(episodes: EpisodeData[], startEpisode: number) {
|
||||||
|
const insertList = episodes.map((ep, idx) => ({
|
||||||
|
projectId: this.projectId,
|
||||||
|
data: JSON.stringify({ ...ep, episodeIndex: startEpisode + idx }),
|
||||||
|
episode: startEpisode + idx,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await u.db("t_outline").insert(insertList);
|
||||||
|
return insertList.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createEmptyScripts(outlineIds: Array<{ id: number; data: string }>) {
|
||||||
|
const scripts = outlineIds.map((item) => {
|
||||||
|
const data = this.safeParseJson<Partial<EpisodeData>>(item.data, {});
|
||||||
|
return {
|
||||||
|
name: `第${data.episodeIndex ?? ""}集`,
|
||||||
|
content: "",
|
||||||
|
projectId: this.projectId,
|
||||||
|
outlineId: item.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scripts.length > 0) {
|
||||||
|
await u.db("t_script").insert(scripts);
|
||||||
|
}
|
||||||
|
return scripts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveOutlineData(episodes: EpisodeData[], overwrite: boolean, startEpisode?: number) {
|
||||||
|
if (overwrite) {
|
||||||
|
const cleared = await this.clearOutlinesAndScripts();
|
||||||
|
if (cleared > 0) {
|
||||||
|
this.log("清理旧数据", `删除了 ${cleared} 条大纲及关联剧本`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualStart = overwrite ? 1 : startEpisode ?? (await this.getMaxEpisode()) + 1;
|
||||||
|
const insertedCount = await this.insertOutlines(episodes, actualStart);
|
||||||
|
|
||||||
|
const newOutlines = await u
|
||||||
|
.db("t_outline")
|
||||||
|
.select("id", "data")
|
||||||
|
.where({ projectId: this.projectId })
|
||||||
|
.orderBy("episode", "desc")
|
||||||
|
.limit(insertedCount);
|
||||||
|
|
||||||
|
const scriptCount = await this.createEmptyScripts(newOutlines as Array<{ id: number; data: string }>);
|
||||||
|
|
||||||
|
this.refresh("outline");
|
||||||
|
return { insertedCount, scriptCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateOutlineData(id: number, data: EpisodeData) {
|
||||||
|
const existing = await this.findOutlineById(id);
|
||||||
|
if (!existing) return false;
|
||||||
|
|
||||||
|
await u
|
||||||
|
.db("t_outline")
|
||||||
|
.where({ id })
|
||||||
|
.update({ data: JSON.stringify(data) });
|
||||||
|
this.refresh("outline");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteOutlineData(ids: number[]) {
|
||||||
|
const results = await Promise.allSettled(ids.map((id) => u.deleteOutline(id, this.projectId)));
|
||||||
|
this.refresh("outline");
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatOutlineDetail(ep: any): string {
|
||||||
|
const formatList = (items: any[], formatter: (item: any) => string) =>
|
||||||
|
items?.map((item, i) => ` ${i + 1}. ${formatter(item)}`).join("\n") || " 无";
|
||||||
|
|
||||||
|
// keyEvents 按顺序显示:起、承、转、合
|
||||||
|
const keyEventsLabels = ["起", "承", "转", "合"];
|
||||||
|
const formatKeyEvents = (events: string[]) => events?.map((e, i) => ` 【${keyEventsLabels[i] || i + 1}】${e}`).join("\n") || " 无";
|
||||||
|
|
||||||
|
return `
|
||||||
|
大纲ID: ${ep.id}
|
||||||
|
第 ${ep.episodeIndex} 集: ${ep.title || ""}
|
||||||
|
${"=".repeat(50)}
|
||||||
|
章节范围: ${ep.chapterRange?.join(", ") || ""}
|
||||||
|
核心矛盾: ${ep.coreConflict || ""}
|
||||||
|
|
||||||
|
【剧情主干】(最高优先级,剧本生成的唯一权威):
|
||||||
|
${ep.outline || "无"}
|
||||||
|
|
||||||
|
【开场镜头】(必须作为剧本第一个镜头):
|
||||||
|
${ep.openingHook || "无"}
|
||||||
|
|
||||||
|
【剧情节点】(严格按顺序:起→承→转→合):
|
||||||
|
${formatKeyEvents(ep.keyEvents)}
|
||||||
|
|
||||||
|
情绪曲线: ${ep.emotionalCurve || ""}
|
||||||
|
|
||||||
|
【视觉重点】(按剧情主干顺序排列):
|
||||||
|
${formatList(ep.visualHighlights, (v) => v)}
|
||||||
|
|
||||||
|
【结尾悬念】:
|
||||||
|
${ep.endingHook || "无"}
|
||||||
|
|
||||||
|
【经典台词】:
|
||||||
|
${formatList(ep.classicQuotes, (q) => q)}
|
||||||
|
|
||||||
|
角色(按出场顺序): ${ep.characters?.map((c: AssetItem) => `${c.name}(${c.description})`).join("; ") || "无"}
|
||||||
|
场景(按出场顺序): ${ep.scenes?.map((s: AssetItem) => `${s.name}(${s.description})`).join("; ") || "无"}
|
||||||
|
道具(按出场顺序): ${ep.props?.map((p: AssetItem) => `${p.name}(${p.description})`).join("; ") || "无"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOutlineText(simplified: boolean): Promise<string> {
|
||||||
|
const records = await this.findOutlines();
|
||||||
|
|
||||||
|
if (!records.length) return "当前项目暂无大纲";
|
||||||
|
|
||||||
|
const episodes = records.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
episode: r.episode,
|
||||||
|
...this.safeParseJson<Partial<EpisodeData>>(r.data ?? "{}", {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (simplified) {
|
||||||
|
const list = episodes.map((ep) => `第 ${ep.episodeIndex ?? ep.episode} 集 (id=${ep.id})`).join("\n");
|
||||||
|
return `项目大纲 (共 ${episodes.length} 集):\n${list}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = episodes.map((ep) => this.formatOutlineDetail(ep)).join("\n");
|
||||||
|
return `项目大纲 (共 ${episodes.length} 集)\n\n${details}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 资产操作 ====================
|
||||||
|
|
||||||
|
private async findAssetByTypeAndName(type: AssetType, name: string) {
|
||||||
|
return u.db("t_assets").where({ projectId: this.projectId, type, name }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertAsset(type: AssetType, item: AssetItem): Promise<"inserted" | "updated" | "skipped"> {
|
||||||
|
const existing = await this.findAssetByTypeAndName(type, item.name);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await u.db("t_assets").insert({
|
||||||
|
projectId: this.projectId,
|
||||||
|
type,
|
||||||
|
name: item.name,
|
||||||
|
intro: item.description,
|
||||||
|
prompt: item.description,
|
||||||
|
});
|
||||||
|
return "inserted";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.intro !== item.description) {
|
||||||
|
await u.db("t_assets").where({ id: existing.id }).update({
|
||||||
|
intro: item.description,
|
||||||
|
prompt: item.description,
|
||||||
|
});
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "skipped";
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAssetsFromOutlines(outlines: Array<{ data?: string | null | undefined }>): {
|
||||||
|
characters: AssetItem[];
|
||||||
|
props: AssetItem[];
|
||||||
|
scenes: AssetItem[];
|
||||||
|
} {
|
||||||
|
const result = { characters: [] as AssetItem[], props: [] as AssetItem[], scenes: [] as AssetItem[] };
|
||||||
|
|
||||||
|
for (const outline of outlines) {
|
||||||
|
const data = this.safeParseJson<Partial<EpisodeData>>(outline.data ?? "{}", {});
|
||||||
|
if (data.characters) result.characters.push(...data.characters);
|
||||||
|
if (data.props) result.props.push(...data.props);
|
||||||
|
if (data.scenes) result.scenes.push(...data.scenes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters: this.uniqueByName(result.characters),
|
||||||
|
props: this.uniqueByName(result.props),
|
||||||
|
scenes: this.uniqueByName(result.scenes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateAssetsFromOutlines() {
|
||||||
|
const outlines = await u.db("t_outline").select("data").where({ projectId: this.projectId });
|
||||||
|
|
||||||
|
if (!outlines.length) return { inserted: 0, updated: 0, skipped: 0 };
|
||||||
|
|
||||||
|
const { characters, props, scenes } = this.extractAssetsFromOutlines(outlines);
|
||||||
|
|
||||||
|
// 只做新增和更新,不做删除
|
||||||
|
const stats = { inserted: 0, updated: 0, skipped: 0 };
|
||||||
|
|
||||||
|
const processItems = async (items: AssetItem[], type: AssetType) => {
|
||||||
|
for (const item of items) {
|
||||||
|
const result = await this.upsertAsset(type, item);
|
||||||
|
stats[result]++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await processItems(characters, "角色");
|
||||||
|
await processItems(props, "道具");
|
||||||
|
await processItems(scenes, "场景");
|
||||||
|
|
||||||
|
this.refresh("assets");
|
||||||
|
return { ...stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Tool 定义:故事线 ====================
|
||||||
|
|
||||||
|
getStoryline = tool(
|
||||||
|
async () => {
|
||||||
|
this.log("获取故事线");
|
||||||
|
const storyline = await this.findStoryline();
|
||||||
|
return storyline?.content ?? "当前项目暂无故事线";
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "getStoryline",
|
||||||
|
description: "获取当前项目的故事线内容",
|
||||||
|
schema: z.object({}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
saveStoryline = tool(
|
||||||
|
async ({ content }) => {
|
||||||
|
this.log("保存故事线");
|
||||||
|
await this.upsertStorylineContent(content);
|
||||||
|
return "故事线保存成功";
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "saveStoryline",
|
||||||
|
description: "保存或更新当前项目的故事线,会覆盖已有内容",
|
||||||
|
schema: z.object({
|
||||||
|
content: z.string().describe("故事线完整内容"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
deleteStoryline = tool(
|
||||||
|
async () => {
|
||||||
|
this.log("删除故事线");
|
||||||
|
const deleted = await this.deleteStorylineContent();
|
||||||
|
return deleted > 0 ? "故事线删除成功" : "当前项目没有故事线";
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deleteStoryline",
|
||||||
|
description: "删除当前项目的故事线",
|
||||||
|
schema: z.object({}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== Tool 定义:大纲 ====================
|
||||||
|
|
||||||
|
getOutline = tool(
|
||||||
|
async ({ simplified = false }) => {
|
||||||
|
this.log("获取大纲", `简化模式: ${simplified}`);
|
||||||
|
return this.getOutlineText(simplified);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "getOutline",
|
||||||
|
description: "获取项目大纲。simplified=true返回简化列表,false返回完整内容",
|
||||||
|
schema: z.object({
|
||||||
|
simplified: z.boolean().default(false).describe("是否返回简化版本"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
saveOutline = tool(
|
||||||
|
async ({ episodes, overwrite = true, startEpisode }) => {
|
||||||
|
this.log("保存大纲", `覆盖模式: ${overwrite}, 集数: ${episodes.length}`);
|
||||||
|
const { insertedCount, scriptCount } = await this.saveOutlineData(episodes as EpisodeData[], overwrite, startEpisode);
|
||||||
|
return `大纲保存成功:插入 ${insertedCount} 集大纲,创建 ${scriptCount} 个剧本记录`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "saveOutline",
|
||||||
|
description: "保存大纲数据。overwrite=true会清空现有大纲后写入,false则追加到末尾",
|
||||||
|
schema: z.object({
|
||||||
|
episodes: z.array(episodeSchema).min(1).describe("大纲数据数组"),
|
||||||
|
overwrite: z.boolean().default(true).describe("是否覆盖现有大纲"),
|
||||||
|
startEpisode: z.number().optional().describe("追加模式下的起始集数(不填则自动递增)"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
updateOutline = tool(
|
||||||
|
async ({ id, data }) => {
|
||||||
|
this.log("更新大纲", `ID: ${id}`);
|
||||||
|
const success = await this.updateOutlineData(id, data as EpisodeData);
|
||||||
|
return success ? `大纲ID ${id} 更新成功` : `未找到大纲ID: ${id}`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updateOutline",
|
||||||
|
description: "更新指定ID的单集大纲内容",
|
||||||
|
schema: z.object({
|
||||||
|
id: z.number().describe("大纲ID"),
|
||||||
|
data: episodeSchema.describe("更新后的大纲数据"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
deleteOutline = tool(
|
||||||
|
async ({ ids }) => {
|
||||||
|
this.log("删除大纲", `IDs: ${ids.join(", ")}`);
|
||||||
|
const results = await this.deleteOutlineData(ids);
|
||||||
|
const summary = results.map((r, i) => `ID ${ids[i]}: ${r.status === "fulfilled" ? "成功" : "失败"}`).join(", ");
|
||||||
|
return `删除结果: ${summary}`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deleteOutline",
|
||||||
|
description: "根据大纲ID删除指定大纲及关联数据",
|
||||||
|
schema: z.object({
|
||||||
|
ids: z.array(z.number()).min(1).describe("要删除的大纲ID数组"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== Tool 定义:章节 ====================
|
||||||
|
|
||||||
|
getChapter = tool(
|
||||||
|
async ({ chapterNumbers }) => {
|
||||||
|
this.log("获取章节", `章节号: ${chapterNumbers.join(", ")}`);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
chapterNumbers.map(async (num) => {
|
||||||
|
const chapter = await u
|
||||||
|
.db("t_novel")
|
||||||
|
.where({ projectId: this.projectId, chapterIndex: num })
|
||||||
|
.select("chapterData", "chapterIndex", "chapter")
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (chapter) {
|
||||||
|
return `\n【第${chapter.chapterIndex}章 ${chapter.chapter || ""}】\n${chapter.chapterData}`;
|
||||||
|
}
|
||||||
|
return `\n【第${num}章】未找到`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.join("\n\n---\n");
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "getChapter",
|
||||||
|
description: "根据章节编号获取小说章节的完整原文内容,支持批量获取",
|
||||||
|
schema: z.object({
|
||||||
|
chapterNumbers: z.array(z.number()).min(1).describe("章节编号数组"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== Tool 定义:资产 ====================
|
||||||
|
|
||||||
|
generateAssets = tool(
|
||||||
|
async () => {
|
||||||
|
this.log("生成资产");
|
||||||
|
const stats = await this.generateAssetsFromOutlines();
|
||||||
|
|
||||||
|
if (stats.inserted === 0 && stats.updated === 0 && stats.skipped === 0) {
|
||||||
|
return "当前项目没有大纲数据,无法生成资产";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `资产生成完成:新增 ${stats.inserted},更新 ${stats.updated},保持 ${stats.skipped}`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generateAssets",
|
||||||
|
description: "从当前项目的所有大纲中提取并生成角色、道具、场景资产,自动去重并清理冗余",
|
||||||
|
schema: z.object({}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 上下文构建 ====================
|
||||||
|
|
||||||
|
private getChapterContext(): string {
|
||||||
|
if (!this.novelChapters.length) return "无章节数据";
|
||||||
|
return this.novelChapters.map((c) => `章节号:${c.chapterIndex},分卷:${c.reel},章节名:${c.chapter}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildEnvironmentContext(): Promise<string> {
|
||||||
|
const [novelInfo, storyline, outlineCount] = await Promise.all([
|
||||||
|
this.getNovelInfo(true),
|
||||||
|
this.findStoryline(),
|
||||||
|
u.db("t_outline").where({ projectId: this.projectId }).count("id as count").first() as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return `<环境信息>
|
||||||
|
项目ID: ${this.projectId}
|
||||||
|
系统时间: ${new Date().toLocaleString()}
|
||||||
|
|
||||||
|
${novelInfo}
|
||||||
|
|
||||||
|
已加载章节列表:
|
||||||
|
${this.getChapterContext()}
|
||||||
|
|
||||||
|
故事线状态: ${storyline ? "已生成" : "未生成"}
|
||||||
|
大纲状态: 共 ${outlineCount?.count ?? 0} 集
|
||||||
|
|
||||||
|
可用工具:
|
||||||
|
- getChapter: 获取章节原文
|
||||||
|
- getStoryline/saveStoryline/deleteStoryline: 故事线操作
|
||||||
|
- getOutline/saveOutline/updateOutline/deleteOutline: 大纲操作
|
||||||
|
- generateAssets: 从大纲生成资产
|
||||||
|
</环境信息>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConversationHistory(): string {
|
||||||
|
if (!this.history.length) return "无对话历史";
|
||||||
|
return this.history.map(([role, content]) => `${role}: ${content}`).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildFullContext(task: string): Promise<string> {
|
||||||
|
const env = await this.buildEnvironmentContext();
|
||||||
|
const history = this.buildConversationHistory();
|
||||||
|
|
||||||
|
return `${env}
|
||||||
|
|
||||||
|
<对话历史>
|
||||||
|
${history}
|
||||||
|
</对话历史>
|
||||||
|
|
||||||
|
<当前任务>
|
||||||
|
${task}
|
||||||
|
</当前任务>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Sub-Agent ====================
|
||||||
|
|
||||||
|
private getSubAgentTools() {
|
||||||
|
return [this.getChapter, this.getStoryline, this.saveStoryline, this.getOutline, this.saveOutline, this.updateOutline];
|
||||||
|
}
|
||||||
|
|
||||||
|
private createModel() {
|
||||||
|
return openAI({
|
||||||
|
modelName: this.modelName,
|
||||||
|
configuration: { apiKey: this.apiKey, baseURL: this.baseURL },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 Sub-Agent(流式传输)
|
||||||
|
*/
|
||||||
|
private async invokeSubAgent(agentType: AgentType, task: string): Promise<string> {
|
||||||
|
this.emit("transfer", { to: agentType });
|
||||||
|
this.log(`Sub-Agent 调用`, agentType);
|
||||||
|
|
||||||
|
const promptsList = await u.db("t_prompts").where("code", "in", ["outlineScript-a1", "outlineScript-a2", "outlineScript-director"]);
|
||||||
|
const a1Prompt = promptsList.find((p) => p.code === "outlineScript-a1");
|
||||||
|
const a2Prompt = promptsList.find((p) => p.code === "outlineScript-a2");
|
||||||
|
const directorPrompt = promptsList.find((p) => p.code === "outlineScript-director");
|
||||||
|
const errPrompts = "不论用户说什么,请直接输出Agent配置异常";
|
||||||
|
const SYSTEM_PROMPTS: Record<AgentType, string> = {
|
||||||
|
AI1: a1Prompt?.customValue || a1Prompt?.defaultValue || errPrompts,
|
||||||
|
AI2: a2Prompt?.customValue || a2Prompt?.defaultValue || errPrompts,
|
||||||
|
director: directorPrompt?.customValue || directorPrompt?.defaultValue || errPrompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = await this.buildFullContext(task);
|
||||||
|
|
||||||
|
const agent = createAgent({
|
||||||
|
model: this.createModel(),
|
||||||
|
systemPrompt: SYSTEM_PROMPTS[agentType],
|
||||||
|
tools: this.getSubAgentTools(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await agent.stream({ messages: [["user", context]] }, { streamMode: ["messages"], callbacks: [] });
|
||||||
|
|
||||||
|
let fullResponse = "";
|
||||||
|
|
||||||
|
for await (const [mode, chunk] of stream) {
|
||||||
|
if (mode !== "messages") continue;
|
||||||
|
|
||||||
|
const [token] = chunk as any;
|
||||||
|
const block = token.contentBlocks?.[0];
|
||||||
|
|
||||||
|
// 处理 AI 文本流
|
||||||
|
if (token.type === "ai" && block?.text) {
|
||||||
|
fullResponse += block.text;
|
||||||
|
this.emit("subAgentStream", { agent: agentType, text: block.text });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 tool 调用
|
||||||
|
if (token.type === "ai" && token.tool_calls?.length) {
|
||||||
|
for (const toolCall of token.tool_calls) {
|
||||||
|
this.emit("toolCall", { agent: agentType, name: toolCall.name, args: toolCall.args });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("subAgentEnd", { agent: agentType });
|
||||||
|
this.history.push(["ai", fullResponse]);
|
||||||
|
this.log(`Sub-Agent 完成`, agentType);
|
||||||
|
|
||||||
|
return fullResponse ?? `${agentType}已完成任务`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSubAgentTool(agentType: AgentType, description: string) {
|
||||||
|
return tool(async ({ taskDescription }) => this.invokeSubAgent(agentType, taskDescription), {
|
||||||
|
name: agentType,
|
||||||
|
description,
|
||||||
|
schema: z.object({
|
||||||
|
taskDescription: z.string().describe("具体的任务描述,包含章节范围、修改要求等详细信息"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主入口 ====================
|
||||||
|
|
||||||
|
private getAllTools() {
|
||||||
|
return [
|
||||||
|
this.createSubAgentTool("AI1", "调用故事师。负责分析小说原文并生成故事线,会自行调用 saveStoryline 保存结果。"),
|
||||||
|
this.createSubAgentTool("AI2", "调用大纲师。负责根据故事线生成剧集大纲,会自行调用 saveOutline 保存结果。"),
|
||||||
|
this.createSubAgentTool("director", "调用导演。负责审核故事线和大纲,会自行调用 updateOutline 或 saveStoryline 进行修改。"),
|
||||||
|
this.getChapter,
|
||||||
|
this.getStoryline,
|
||||||
|
this.saveStoryline,
|
||||||
|
this.deleteStoryline,
|
||||||
|
this.getOutline,
|
||||||
|
this.saveOutline,
|
||||||
|
this.updateOutline,
|
||||||
|
this.deleteOutline,
|
||||||
|
this.generateAssets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(msg: string): Promise<string> {
|
||||||
|
this.history.push(["user", msg]);
|
||||||
|
|
||||||
|
const envContext = await this.buildEnvironmentContext();
|
||||||
|
|
||||||
|
const prompts = await u.db("t_prompts").where("code", "outlineScript-main").first();
|
||||||
|
|
||||||
|
const mainPrompts = prompts?.customValue || prompts?.defaultValue || "不论用户说什么,请直接输出Agent配置异常";
|
||||||
|
|
||||||
|
const mainAgent = createAgent({
|
||||||
|
model: this.createModel(),
|
||||||
|
tools: this.getAllTools(),
|
||||||
|
systemPrompt: `${envContext}\n${mainPrompts}`,
|
||||||
|
});
|
||||||
|
const stream = await mainAgent.stream({ messages: this.history }, { streamMode: ["messages"], callbacks: [] });
|
||||||
|
|
||||||
|
let fullResponse = "";
|
||||||
|
|
||||||
|
for await (const [mode, chunk] of stream) {
|
||||||
|
if (mode !== "messages") continue;
|
||||||
|
|
||||||
|
const [token] = chunk as any;
|
||||||
|
const block = token.contentBlocks?.[0];
|
||||||
|
|
||||||
|
// 处理 AI 文本流
|
||||||
|
if (token.type === "ai" && block?.text) {
|
||||||
|
fullResponse += block.text;
|
||||||
|
this.emit("data", block.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 tool 调用
|
||||||
|
if (token.type === "ai" && token.tool_calls?.length) {
|
||||||
|
for (const toolCall of token.tool_calls) {
|
||||||
|
this.emit("toolCall", { agent: "main", name: toolCall.name, args: toolCall.args });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.history.push(["assistant", fullResponse]);
|
||||||
|
this.emit("response", fullResponse);
|
||||||
|
|
||||||
|
return fullResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/agents/storyboard/generateImagePromptsTool.ts
Normal file
130
src/agents/storyboard/generateImagePromptsTool.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import u from "@/utils";
|
||||||
|
|
||||||
|
type AspectRatio = "16:9" | "9:16" | "21:9" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3";
|
||||||
|
|
||||||
|
interface GridLayoutResult {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
totalCells: number;
|
||||||
|
placeholderCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GridPromptOptions {
|
||||||
|
prompts: string[];
|
||||||
|
style: string;
|
||||||
|
aspectRatio: AspectRatio;
|
||||||
|
assetsName: { name: string; intro: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GridPromptResult {
|
||||||
|
prompt: string;
|
||||||
|
gridLayout: GridLayoutResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据prompts数量计算宫格布局
|
||||||
|
*/
|
||||||
|
function calculateGridLayout(count: number): GridLayoutResult {
|
||||||
|
let cols: number;
|
||||||
|
let rows: number;
|
||||||
|
if (count <= 0) {
|
||||||
|
cols = 1;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 1) {
|
||||||
|
cols = 1;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 2) {
|
||||||
|
cols = 2;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 3) {
|
||||||
|
cols = 3;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 4) {
|
||||||
|
cols = 2;
|
||||||
|
rows = 2;
|
||||||
|
} else if (count <= 9) {
|
||||||
|
cols = 3;
|
||||||
|
rows = 3;
|
||||||
|
} else {
|
||||||
|
cols = 3;
|
||||||
|
rows = Math.ceil(count / 3);
|
||||||
|
}
|
||||||
|
const totalCells = cols * rows;
|
||||||
|
const placeholderCount = totalCells - count;
|
||||||
|
return { cols, rows, totalCells, placeholderCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取宽高比描述
|
||||||
|
*/
|
||||||
|
function getAspectRatioDescription(aspectRatio: AspectRatio): string {
|
||||||
|
const descriptions: Record<AspectRatio, string> = {
|
||||||
|
"16:9": "电影宽银幕",
|
||||||
|
"9:16": "竖屏短剧",
|
||||||
|
"21:9": "超宽银幕史诗感",
|
||||||
|
"1:1": "方形构图",
|
||||||
|
"4:3": "经典银幕",
|
||||||
|
"3:4": "竖版经典",
|
||||||
|
"3:2": "摄影标准",
|
||||||
|
"2:3": "竖版摄影",
|
||||||
|
};
|
||||||
|
return descriptions[aspectRatio] || "标准比例";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成电影级宫格分镜提示词
|
||||||
|
*/
|
||||||
|
async function generateGridPrompt(options: GridPromptOptions): Promise<GridPromptResult> {
|
||||||
|
const { prompts, style, aspectRatio, assetsName } = options;
|
||||||
|
const layout = calculateGridLayout(prompts.length);
|
||||||
|
const aspectRatioDesc = getAspectRatioDescription(aspectRatio);
|
||||||
|
|
||||||
|
// 构建宫格位置描述
|
||||||
|
const gridPositions: string[] = [];
|
||||||
|
for (let i = 0; i < layout.totalCells; i++) {
|
||||||
|
const row = Math.floor(i / layout.cols) + 1;
|
||||||
|
const col = (i % layout.cols) + 1;
|
||||||
|
if (i < prompts.length) {
|
||||||
|
gridPositions.push(`[第${row}行第${col}列]: ${prompts[i]}`);
|
||||||
|
} else {
|
||||||
|
gridPositions.push(`[第${row}行第${col}列]: 纯黑图`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建资产说明
|
||||||
|
const assetsSection =
|
||||||
|
assetsName.length > 0
|
||||||
|
? `\n【可用资产】\n${assetsName.map((a) => `- ${a.name}:${a.intro}`).join("\n")}\n\n⚠️ 必须使用完整资产名称,禁止简称或代词。`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const promptsData = await u.db("t_prompts").where("code", "generateImagePrompts").first();
|
||||||
|
|
||||||
|
const mainPrompts = promptsData?.customValue || promptsData?.defaultValue;
|
||||||
|
const errData = `请输出${options.prompts.length}张图片\n提示词如下:\n${options.prompts.map((p, i) => `第${i + 1}格: ${p}`).join("\n")}`;
|
||||||
|
|
||||||
|
if (!mainPrompts) return { prompt: errData, gridLayout: layout };
|
||||||
|
|
||||||
|
const chatModel = await u.ai.text({});
|
||||||
|
|
||||||
|
const result = await chatModel!.invoke({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: mainPrompts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `请优化以下分镜提示词:\n\n【布局】${layout.cols}列×${layout.rows}行=${
|
||||||
|
layout.totalCells
|
||||||
|
}格\n【比例】${aspectRatio}(${aspectRatioDesc})\n【风格】${style}\n${assetsSection}\n\n【原始内容】\n${gridPositions.join("\n")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: result?.text ?? errData,
|
||||||
|
gridLayout: layout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default generateGridPrompt;
|
||||||
262
src/agents/storyboard/generateImageTool.ts
Normal file
262
src/agents/storyboard/generateImageTool.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import generateImagePromptsTool from "@/agents/storyboard/generateImagePromptsTool";
|
||||||
|
import u from "@/utils";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface AssetItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EpisodeData {
|
||||||
|
episodeIndex: number;
|
||||||
|
title: string;
|
||||||
|
chapterRange: number[];
|
||||||
|
scenes: AssetItem[];
|
||||||
|
characters: AssetItem[];
|
||||||
|
props: AssetItem[];
|
||||||
|
coreConflict: string;
|
||||||
|
openingHook: string;
|
||||||
|
outline: string;
|
||||||
|
keyEvents: string[];
|
||||||
|
emotionalCurve: string;
|
||||||
|
visualHighlights: string[];
|
||||||
|
endingHook: string;
|
||||||
|
classicQuotes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceItem {
|
||||||
|
name: string;
|
||||||
|
intro: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资产过滤响应的 schema
|
||||||
|
const filteredAssetsSchema = z.object({
|
||||||
|
relevantAssets: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().describe("资产名称"),
|
||||||
|
reason: z.string().describe("选择该资产的原因"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe("与分镜内容相关的资产列表"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 压缩图片直到不超过指定大小
|
||||||
|
async function compressImage(buffer: Buffer, maxSizeBytes: number = 3 * 1024 * 1024): Promise<Buffer> {
|
||||||
|
if (buffer.length <= maxSizeBytes) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
let quality = 90;
|
||||||
|
let compressedBuffer = await sharp(buffer).jpeg({ quality }).toBuffer();
|
||||||
|
while (compressedBuffer.length > maxSizeBytes && quality > 10) {
|
||||||
|
quality -= 10;
|
||||||
|
compressedBuffer = await sharp(buffer).jpeg({ quality }).toBuffer();
|
||||||
|
}
|
||||||
|
if (compressedBuffer.length > maxSizeBytes) {
|
||||||
|
const metadata = await sharp(buffer).metadata();
|
||||||
|
let scale = 0.9;
|
||||||
|
while (compressedBuffer.length > maxSizeBytes && scale > 0.1) {
|
||||||
|
const newWidth = Math.round((metadata.width || 1000) * scale);
|
||||||
|
const newHeight = Math.round((metadata.height || 1000) * scale);
|
||||||
|
compressedBuffer = await sharp(buffer)
|
||||||
|
.resize(newWidth, newHeight, { fit: "inside" })
|
||||||
|
.jpeg({ quality: Math.max(quality, 30) })
|
||||||
|
.toBuffer();
|
||||||
|
scale -= 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compressedBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接多张图片为一张
|
||||||
|
async function mergeImages(imagePaths: string[]): Promise<Buffer> {
|
||||||
|
const imageBuffers = await Promise.all(imagePaths.map((path) => u.oss.getFile(path)));
|
||||||
|
const imageMetadatas = await Promise.all(imageBuffers.map((buffer) => sharp(buffer).metadata()));
|
||||||
|
const maxHeight = Math.max(...imageMetadatas.map((m) => m.height || 0));
|
||||||
|
const resizedImages = await Promise.all(
|
||||||
|
imageBuffers.map(async (buffer, index) => {
|
||||||
|
const metadata = imageMetadatas[index];
|
||||||
|
const aspectRatio = (metadata.width || 1) / (metadata.height || 1);
|
||||||
|
const newWidth = Math.round(maxHeight * aspectRatio);
|
||||||
|
return {
|
||||||
|
buffer: await sharp(buffer).resize(newWidth, maxHeight, { fit: "cover" }).toBuffer(),
|
||||||
|
width: newWidth,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let currentX = 0;
|
||||||
|
const compositeInputs = resizedImages.map(({ buffer, width }) => {
|
||||||
|
const input = {
|
||||||
|
input: buffer,
|
||||||
|
left: currentX,
|
||||||
|
top: 0,
|
||||||
|
};
|
||||||
|
currentX += width;
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
const mergedImage = await sharp({
|
||||||
|
create: {
|
||||||
|
width: currentX,
|
||||||
|
height: maxHeight,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite(compositeInputs)
|
||||||
|
.jpeg({ quality: 90 })
|
||||||
|
.toBuffer();
|
||||||
|
return compressImage(mergedImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片列表,确保不超过10张且每张不超过3MB
|
||||||
|
async function processImages(images: ImageInfo[]): Promise<Buffer[]> {
|
||||||
|
const maxImages = 10;
|
||||||
|
if (images.length <= maxImages) {
|
||||||
|
const buffers = await Promise.all(images.map((img) => u.oss.getFile(img.filePath)));
|
||||||
|
return Promise.all(buffers.map((buffer) => compressImage(buffer)));
|
||||||
|
}
|
||||||
|
const mergeStartIndex = maxImages - 1;
|
||||||
|
const firstBuffers = await Promise.all(images.slice(0, mergeStartIndex).map((img) => u.oss.getFile(img.filePath)));
|
||||||
|
const compressedFirstImages = await Promise.all(firstBuffers.map((buffer) => compressImage(buffer)));
|
||||||
|
const imagesToMergeList = images.slice(mergeStartIndex).map((img) => img.filePath);
|
||||||
|
const mergedImage = await mergeImages(imagesToMergeList);
|
||||||
|
return [...compressedFirstImages, mergedImage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 AI 过滤与分镜相关的资产
|
||||||
|
async function filterRelevantAssets(prompts: string[], allResources: ResourceItem[], availableImages: ImageInfo[]): Promise<ImageInfo[]> {
|
||||||
|
if (allResources.length === 0 || availableImages.length === 0) {
|
||||||
|
return availableImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableNames = new Set(availableImages.map((img) => img.name));
|
||||||
|
const availableResources = allResources.filter((r) => availableNames.has(r.name));
|
||||||
|
|
||||||
|
if (availableResources.length === 0) {
|
||||||
|
return availableImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatModel = await u.ai.text({});
|
||||||
|
const result = await chatModel!.invoke({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `请分析以下分镜描述,从可用资产中筛选出与分镜内容直接相关的资产。
|
||||||
|
|
||||||
|
分镜描述:
|
||||||
|
${prompts.map((p, i) => `${i + 1}. ${p}`).join("\n")}
|
||||||
|
|
||||||
|
可用资产列表:
|
||||||
|
${availableResources.map((r) => `- ${r.name}:${r.intro}`).join("\n")}
|
||||||
|
|
||||||
|
请仅选择在分镜中明确出现或被提及的角色、场景、道具。不要选择与分镜内容无关的资产。`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responseFormat: {
|
||||||
|
type: "json_schema",
|
||||||
|
jsonSchema: {
|
||||||
|
name: "filteredAssets",
|
||||||
|
strict: true,
|
||||||
|
schema: z.toJSONSchema(filteredAssetsSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = result?.json as z.infer<typeof filteredAssetsSchema>;
|
||||||
|
|
||||||
|
if (!data?.relevantAssets || data.relevantAssets.length === 0) {
|
||||||
|
return availableImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevantNames = new Set(data.relevantAssets.map((a) => a.name));
|
||||||
|
const filteredImages = availableImages.filter((img) => relevantNames.has(img.name));
|
||||||
|
|
||||||
|
return filteredImages.length > 0 ? filteredImages : availableImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建资产映射提示词
|
||||||
|
function buildResourcesMapPrompts(images: ImageInfo[]): string {
|
||||||
|
if (images.length === 0) return "";
|
||||||
|
|
||||||
|
const mapping = images.map((item, index) => {
|
||||||
|
if (index < 9) {
|
||||||
|
return `${item.name}=图片${index + 1}`;
|
||||||
|
} else {
|
||||||
|
return `${item.name}=图10-${index - 8}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `其中人物、场景、道具参考对照关系如下:${mapping.join(", ")}。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async (cells: { prompt: string }[], scriptId: number, projectId: number) => {
|
||||||
|
const scriptData = await u.db("t_script").where({ id: scriptId, projectId }).first();
|
||||||
|
const projectInfo = await u.db("t_project").where({ id: projectId }).first();
|
||||||
|
|
||||||
|
const row = await u.db("t_outline").where({ id: scriptData?.outlineId!, projectId }).first();
|
||||||
|
const outline: EpisodeData | null = row?.data ? JSON.parse(row.data) : null;
|
||||||
|
|
||||||
|
const resources: ResourceItem[] = outline
|
||||||
|
? (["characters", "props", "scenes"] as const).flatMap((k) => outline[k]?.map((i) => ({ name: i.name, intro: i.description })) ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const resourceNames = resources.map((r) => r.name);
|
||||||
|
const imagesRaw = await u.db("t_assets").whereIn("name", resourceNames).andWhere({ projectId }).select("name", "type", "filePath");
|
||||||
|
|
||||||
|
const allImages = imagesRaw
|
||||||
|
.sort((a, b) => {
|
||||||
|
const order = ["角色", "场景", "道具"];
|
||||||
|
return order.indexOf(a.type!) - order.indexOf(b.type!);
|
||||||
|
})
|
||||||
|
.filter((img) => img.filePath) as ImageInfo[];
|
||||||
|
|
||||||
|
if (allImages.length === 0) {
|
||||||
|
throw new Error("未找到可用的图片资源");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellPrompts = cells.map((c) => c.prompt);
|
||||||
|
|
||||||
|
// 使用 AI 过滤相关资产
|
||||||
|
const filteredImages = await filterRelevantAssets(cellPrompts, resources, allImages);
|
||||||
|
|
||||||
|
const resourcesMapPrompts = buildResourcesMapPrompts(filteredImages);
|
||||||
|
console.log("====润色前:", cellPrompts);
|
||||||
|
const promptsData = await generateImagePromptsTool({
|
||||||
|
prompts: cellPrompts,
|
||||||
|
style: `类型:${projectInfo?.type!},风格:${projectInfo?.artStyle!}`,
|
||||||
|
aspectRatio: projectInfo?.videoRatio! as any,
|
||||||
|
assetsName: resources,
|
||||||
|
});
|
||||||
|
|
||||||
|
// const prompts = `请生成${promptsData.gridLayout.totalCells}格,${promptsData.gridLayout.cols}列×${promptsData.gridLayout.rows}行宫格图。
|
||||||
|
|
||||||
|
// ${promptsData.prompt}
|
||||||
|
|
||||||
|
// 注意:请严格按照提示词内容生成图片,确保人物样貌、艺术风格、色调光影一致。
|
||||||
|
// `;
|
||||||
|
const prompts = promptsData.prompt;
|
||||||
|
console.log("====润色后:", prompts);
|
||||||
|
|
||||||
|
const processedImages = await processImages(filteredImages);
|
||||||
|
|
||||||
|
const contentStr = await u.ai.generateImage({
|
||||||
|
systemPrompt: resourcesMapPrompts,
|
||||||
|
prompt: prompts,
|
||||||
|
size: "4K",
|
||||||
|
aspectRatio: projectInfo?.videoRatio ? (projectInfo.videoRatio as any) : "16:9",
|
||||||
|
imageBase64: processedImages.map((buf) => buf.toString("base64")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const match = contentStr.match(/base64,([A-Za-z0-9+/=]+)/);
|
||||||
|
const base64Str = match?.[1] ?? contentStr;
|
||||||
|
const buffer = Buffer.from(base64Str, "base64");
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
94
src/agents/storyboard/imageSplitting.ts
Normal file
94
src/agents/storyboard/imageSplitting.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
interface GridLayoutResult {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
totalCells: number;
|
||||||
|
placeholderCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算宫格布局
|
||||||
|
* 1张: 1x1
|
||||||
|
* 2张: 2x1
|
||||||
|
* 3张: 3x1
|
||||||
|
* 4张: 2x2
|
||||||
|
* 5-9张: 3x3
|
||||||
|
* 10-12张: 3x4
|
||||||
|
* 13-15张: 3x5
|
||||||
|
* ...以此类推(3列,行数递增)
|
||||||
|
*/
|
||||||
|
function calculateGridLayout(count: number): GridLayoutResult {
|
||||||
|
let cols: number;
|
||||||
|
let rows: number;
|
||||||
|
if (count <= 0) {
|
||||||
|
cols = 1;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 1) {
|
||||||
|
cols = 1;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 2) {
|
||||||
|
cols = 2;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 3) {
|
||||||
|
cols = 3;
|
||||||
|
rows = 1;
|
||||||
|
} else if (count === 4) {
|
||||||
|
cols = 2;
|
||||||
|
rows = 2;
|
||||||
|
} else if (count <= 9) {
|
||||||
|
// 5-9格统一用3x3
|
||||||
|
cols = 3;
|
||||||
|
rows = 3;
|
||||||
|
} else {
|
||||||
|
cols = 3;
|
||||||
|
rows = Math.ceil(count / 3);
|
||||||
|
}
|
||||||
|
const totalCells = cols * rows;
|
||||||
|
const placeholderCount = totalCells - count;
|
||||||
|
return { cols, rows, totalCells, placeholderCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分割宫格图片
|
||||||
|
* @param image - 输入的宫格图片 Buffer
|
||||||
|
* @param length - 实际需要的图片数量(不包含占位图)
|
||||||
|
* @returns 分割后的单张图片 Buffer 数组
|
||||||
|
*/
|
||||||
|
export default async (image: Buffer, length: number): Promise<Buffer[]> => {
|
||||||
|
const metadata = await sharp(image).metadata();
|
||||||
|
const { width: totalWidth, height: totalHeight } = metadata;
|
||||||
|
|
||||||
|
if (!totalWidth || !totalHeight) {
|
||||||
|
throw new Error("无法获取图片尺寸");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cols, rows } = calculateGridLayout(length);
|
||||||
|
|
||||||
|
const cellWidth = Math.floor(totalWidth / cols);
|
||||||
|
const cellHeight = Math.floor(totalHeight / rows);
|
||||||
|
|
||||||
|
const buffers: Buffer[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
const col = i % cols;
|
||||||
|
|
||||||
|
const left = col * cellWidth;
|
||||||
|
const top = row * cellHeight;
|
||||||
|
|
||||||
|
const cellBuffer = await sharp(image)
|
||||||
|
.extract({
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width: cellWidth,
|
||||||
|
height: cellHeight,
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
buffers.push(cellBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffers;
|
||||||
|
};
|
||||||
737
src/agents/storyboard/index.ts
Normal file
737
src/agents/storyboard/index.ts
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
// @/agents/Storyboard.ts
|
||||||
|
import u from "@/utils";
|
||||||
|
import { createAgent } from "langchain";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { openAI } from "@/agents/models";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tool } from "@langchain/core/tools";
|
||||||
|
import type { DB } from "@/types/database";
|
||||||
|
import generateImageTool from "./generateImageTool";
|
||||||
|
import imageSplitting from "./imageSplitting";
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
type AgentType = "segmentAgent" | "shotAgent";
|
||||||
|
type RefreshEvent = "storyline" | "outline" | "assets";
|
||||||
|
|
||||||
|
// ==================== 常量配置 ====================
|
||||||
|
|
||||||
|
// const SYSTEM_PROMPTS: Record<AgentType, string> = {
|
||||||
|
// segmentAgent: segmentPrompts,
|
||||||
|
// shotAgent: shotPrompts,
|
||||||
|
// director: directorPrompts,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// ==================== 类型定义:片段和画面 ====================
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
index: number;
|
||||||
|
description: string;
|
||||||
|
emotion?: string;
|
||||||
|
action?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Shot {
|
||||||
|
id: number; // 分镜独立ID
|
||||||
|
segmentId: number; // 所属片段ID
|
||||||
|
title: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
cells: Array<{ src?: string; prompt?: string; id?: string }>; // 镜头数组,每个cell是一个镜头
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主类 ====================
|
||||||
|
|
||||||
|
export default class Storyboard {
|
||||||
|
private readonly projectId: number;
|
||||||
|
private readonly scriptId: number;
|
||||||
|
readonly emitter = new EventEmitter();
|
||||||
|
history: Array<[string, string]> = [];
|
||||||
|
novelChapters: DB["t_novel"][] = [];
|
||||||
|
|
||||||
|
// 存储 segmentAgent 生成的片段结果
|
||||||
|
private segments: Segment[] = [];
|
||||||
|
// 存储 shotAgent 生成的分镜结果
|
||||||
|
private shots: Shot[] = [];
|
||||||
|
// 分镜ID计数器
|
||||||
|
private shotIdCounter: number = 0;
|
||||||
|
// 存储正在生成分镜图的分镜ID
|
||||||
|
private generatingShots: Set<number> = new Set();
|
||||||
|
|
||||||
|
modelName = "gpt-4.1";
|
||||||
|
apiKey = "";
|
||||||
|
baseURL = "";
|
||||||
|
|
||||||
|
constructor(projectId: number, scriptId: number) {
|
||||||
|
this.projectId = projectId;
|
||||||
|
this.scriptId = scriptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新shopts
|
||||||
|
public updatePreShots(segmentId: number, cellId: number, cell: { src?: string; prompt?: string; id?: string }) {
|
||||||
|
console.log("%c Line:76 🍤 segmentId", "background:#465975", segmentId);
|
||||||
|
console.log("%c Line:76 🍷 cellId", "background:#ffdd4d", cellId);
|
||||||
|
console.log("%c Line:76 🍢 cell", "background:#ffdd4d", cell);
|
||||||
|
const shotIndex = this.shots.findIndex((item) => item.segmentId === segmentId);
|
||||||
|
if (shotIndex === -1) {
|
||||||
|
return `分镜 ${segmentId} 不存在,请检查分镜ID是否正确`;
|
||||||
|
}
|
||||||
|
const cellIndex = this.shots[shotIndex].cells.findIndex((item) => item.id === cellId.toString());
|
||||||
|
if (cellIndex === -1) {
|
||||||
|
return `镜头 ${cellId} 不存在,请检查镜头ID是否正确`;
|
||||||
|
}
|
||||||
|
this.shots[shotIndex].cells[cellIndex] = { ...this.shots[shotIndex].cells[cellIndex], ...cell };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 公共方法 ====================
|
||||||
|
|
||||||
|
get events() {
|
||||||
|
return this.emitter;
|
||||||
|
}
|
||||||
|
// ==================== 私有工具方法 ====================
|
||||||
|
|
||||||
|
private emit(event: string, data?: any) {
|
||||||
|
this.emitter.emit(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(type: RefreshEvent) {
|
||||||
|
this.emit("refresh", type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(action: string, detail?: string) {
|
||||||
|
const msg = detail ? `${action}: ${detail}` : action;
|
||||||
|
console.log(`\n[${new Date().toLocaleTimeString()}] ${msg}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 剧本相关操作 ====================
|
||||||
|
|
||||||
|
getScript = tool(
|
||||||
|
async () => {
|
||||||
|
this.log("获取剧本", `scriptId: ${this.scriptId}`);
|
||||||
|
const script = await u.db("t_script").where({ id: this.scriptId, projectId: this.projectId }).first();
|
||||||
|
if (!script) throw new Error("剧本不存在");
|
||||||
|
return `剧本集:${script.name}\n\n内容:\n\`\`\`${script.content}\`\`\``;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "getScript",
|
||||||
|
description: "获取剧本内容",
|
||||||
|
schema: z.object({}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 资产相关操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资产列表(供 segmentAgent 和 shotAgent 调用)
|
||||||
|
*/
|
||||||
|
getAssets = tool(
|
||||||
|
async () => {
|
||||||
|
this.log("获取资产列表", `scriptId: ${this.scriptId}`);
|
||||||
|
const scriptData = await u.db("t_script").where({ id: this.scriptId, projectId: this.projectId }).first();
|
||||||
|
const row = await u.db("t_outline").where({ id: scriptData?.outlineId!, projectId: this.projectId }).first();
|
||||||
|
const outline: any | null = row?.data ? JSON.parse(row.data) : null;
|
||||||
|
|
||||||
|
if (!outline) {
|
||||||
|
return "暂无资产数据";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取资源名称和描述(与generateImageTool保持一致的字段名)
|
||||||
|
const resources = outline
|
||||||
|
? (["characters", "props", "scenes"] as const).flatMap(
|
||||||
|
(k) => outline[k]?.map((i: any) => ({ name: i.name, description: i.description })) ?? [],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return "暂无资产数据";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类提取资源并格式化
|
||||||
|
const characters = outline?.characters?.map((item: any) => `- ${item.name}${item.description ? `:${item.description}` : ""}`) ?? [];
|
||||||
|
const props = outline?.props?.map((item: any) => `- ${item.name}${item.description ? `:${item.description}` : ""}`) ?? [];
|
||||||
|
const scenes = outline?.scenes?.map((item: any) => `- ${item.name}${item.description ? `:${item.description}` : ""}`) ?? [];
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
characters.length ? `【角色】\n${characters.join("\n")}` : "",
|
||||||
|
props.length ? `【道具】\n${props.join("\n")}` : "",
|
||||||
|
scenes.length ? `【场景】\n${scenes.join("\n")}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
return "暂无资产数据";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<资产列表>
|
||||||
|
${sections.join("\n\n")}
|
||||||
|
</资产列表>
|
||||||
|
|
||||||
|
⚠️ 重要规则:
|
||||||
|
1. 必须原封不动地使用上述资产名称,禁止使用近义词、缩写或任何变体
|
||||||
|
2. 禁止在资产名称前后添加修饰词
|
||||||
|
3. 禁止捏造资产列表中不存在的角色、场景、道具`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "getAssets",
|
||||||
|
description: "获取资产列表(角色、道具、场景),包含名称和详细介绍。生成片段和分镜时必须先调用此工具获取资产信息,确保名称一致性",
|
||||||
|
schema: z.object({}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 片段和分镜工具 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前存储的片段数据(供 shotAgent 调用)
|
||||||
|
*/
|
||||||
|
getSegments = tool(
|
||||||
|
async () => {
|
||||||
|
this.log("获取片段数据", `共 ${this.segments.length} 个片段`);
|
||||||
|
if (this.segments.length === 0) {
|
||||||
|
return "暂无片段数据,请先调用 segmentAgent 生成片段";
|
||||||
|
}
|
||||||
|
return JSON.stringify(this.segments, null, 2);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "getSegments",
|
||||||
|
description: "获取当前已生成的片段数据,用于生成分镜",
|
||||||
|
schema: z.object({}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新/存储片段数据(供 segmentAgent 调用)
|
||||||
|
*/
|
||||||
|
updateSegments = tool(
|
||||||
|
async ({ segments }: { segments: Segment[] }) => {
|
||||||
|
this.log("更新片段数据", `共 ${segments.length} 个片段`);
|
||||||
|
this.segments = segments;
|
||||||
|
this.emit("segmentsUpdated", this.segments);
|
||||||
|
return `成功存储 ${segments.length} 个片段`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updateSegments",
|
||||||
|
description: "存储生成的片段数据,segmentAgent 在生成片段后必须调用此工具保存结果",
|
||||||
|
schema: z.object({
|
||||||
|
segments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
index: z.number().describe("片段序号"),
|
||||||
|
description: z.string().describe("片段描述"),
|
||||||
|
emotion: z.string().optional().describe("情绪氛围"),
|
||||||
|
action: z.string().optional().describe("主要动作"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe("片段数组"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加分镜(供 shotAgent 调用)
|
||||||
|
*/
|
||||||
|
addShots = tool(
|
||||||
|
async ({ shots }: { shots: Array<{ segmentIndex: number; prompts: string[] }> }) => {
|
||||||
|
const added: { id: number; segmentIndex: number }[] = [];
|
||||||
|
const skipped: number[] = [];
|
||||||
|
|
||||||
|
for (const item of shots) {
|
||||||
|
const exists = this.shots.some((f) => f.segmentId === item.segmentIndex);
|
||||||
|
if (exists) {
|
||||||
|
skipped.push(item.segmentIndex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 分配独立的分镜ID
|
||||||
|
this.shotIdCounter++;
|
||||||
|
const shotId = this.shotIdCounter;
|
||||||
|
this.shots.push({
|
||||||
|
id: shotId,
|
||||||
|
segmentId: item.segmentIndex,
|
||||||
|
title: `分镜 ${shotId}`,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
cells: item.prompts.map((prompt) => ({ id: u.uuid(), prompt })),
|
||||||
|
});
|
||||||
|
added.push({ id: shotId, segmentIndex: item.segmentIndex });
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedInfo = added.map((a) => `分镜${a.id}(片段${a.segmentIndex})`).join(", ");
|
||||||
|
this.log("添加分镜", `新增: [${addedInfo}], 跳过片段: [${skipped.join(", ")}]`);
|
||||||
|
this.emit("shotsUpdated", this.shots);
|
||||||
|
|
||||||
|
if (skipped.length) {
|
||||||
|
return `已添加${addedInfo};片段 ${skipped.join(", ")} 已存在分镜被跳过。当前共 ${this.shots.length} 个分镜`;
|
||||||
|
}
|
||||||
|
return `已添加${addedInfo}。当前共 ${this.shots.length} 个分镜`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "addShots",
|
||||||
|
description: "添加新的分镜。每个分镜有独立ID,包含多个镜头(每个镜头对应一个提示词)。如果片段已存在分镜会跳过",
|
||||||
|
schema: z.object({
|
||||||
|
shots: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
segmentIndex: z.number().describe("对应的片段序号"),
|
||||||
|
prompts: z.array(z.string()).describe("镜头提示词数组,每个提示词对应一个镜头(中文)"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe("要添加的分镜数组"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新指定分镜(供 shotAgent 调用)
|
||||||
|
* 保留原有 cells 的 id 和 src 字段,只更新 prompt
|
||||||
|
*/
|
||||||
|
updateShots = tool(
|
||||||
|
async ({ shotId, prompts }: { shotId: number; prompts: string[] }) => {
|
||||||
|
const existingIndex = this.shots.findIndex((item) => item.id === shotId);
|
||||||
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return `分镜 ${shotId} 不存在,请检查分镜ID是否正确`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCells = this.shots[existingIndex].cells;
|
||||||
|
|
||||||
|
// 更新 cells,保留原有的 id 和 src 字段
|
||||||
|
this.shots[existingIndex].cells = prompts.map((prompt, i) => {
|
||||||
|
const existingCell = existingCells[i];
|
||||||
|
if (existingCell) {
|
||||||
|
// 保留原有 cell 的 id 和 src,只更新 prompt
|
||||||
|
return { ...existingCell, prompt };
|
||||||
|
} else {
|
||||||
|
// 新增的 cell
|
||||||
|
return { id: u.uuid(), prompt };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.log("更新分镜", `分镜 ${shotId}`);
|
||||||
|
this.emit("shotsUpdated", this.shots);
|
||||||
|
|
||||||
|
return `已更新分镜 ${shotId}`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updateShots",
|
||||||
|
description: "更新指定分镜的镜头提示词。通过分镜ID指定要修改的分镜",
|
||||||
|
schema: z.object({
|
||||||
|
shotId: z.number().describe("要更新的分镜ID"),
|
||||||
|
prompts: z.array(z.string()).describe("新的镜头提示词数组,每个提示词对应一个镜头"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定分镜(供 shotAgent 调用)
|
||||||
|
*/
|
||||||
|
deleteShots = tool(
|
||||||
|
async ({ shotIds }: { shotIds: number[] }) => {
|
||||||
|
const deleted: number[] = [];
|
||||||
|
const notFound: number[] = [];
|
||||||
|
|
||||||
|
for (const shotId of shotIds) {
|
||||||
|
const idx = this.shots.findIndex((item) => item.id === shotId);
|
||||||
|
if (idx === -1) {
|
||||||
|
notFound.push(shotId);
|
||||||
|
} else {
|
||||||
|
this.shots.splice(idx, 1);
|
||||||
|
deleted.push(shotId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log("删除分镜", `删除: [分镜${deleted.join(", 分镜")}], 未找到: [分镜${notFound.join(", 分镜")}]`);
|
||||||
|
this.emit("shotsUpdated", this.shots);
|
||||||
|
|
||||||
|
if (notFound.length) {
|
||||||
|
return `已删除分镜 ${deleted.join(", ")};分镜 ${notFound.join(", ")} 不存在。当前共 ${this.shots.length} 个分镜`;
|
||||||
|
}
|
||||||
|
return `已删除分镜 ${deleted.join(", ")}。当前共 ${this.shots.length} 个分镜`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deleteShots",
|
||||||
|
description: "删除指定的分镜。通过分镜ID指定要删除的分镜",
|
||||||
|
schema: z.object({
|
||||||
|
shotIds: z.array(z.number()).describe("要删除的分镜ID数组"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成分镜图(异步执行,使用 nanoBanana)
|
||||||
|
*/
|
||||||
|
generateShotImage = tool(
|
||||||
|
async ({ shotIds }: { shotIds: number[] }) => {
|
||||||
|
const toGenerate: number[] = [];
|
||||||
|
const alreadyGenerating: number[] = [];
|
||||||
|
const notFound: number[] = [];
|
||||||
|
|
||||||
|
for (const shotId of shotIds) {
|
||||||
|
const shot = this.shots.find((f) => f.id === shotId);
|
||||||
|
if (!shot) {
|
||||||
|
notFound.push(shotId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (this.generatingShots.has(shotId)) {
|
||||||
|
alreadyGenerating.push(shotId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toGenerate.push(shotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toGenerate.length === 0) {
|
||||||
|
if (notFound.length) {
|
||||||
|
return `分镜 ${notFound.join(", ")} 不存在,请检查分镜ID是否正确`;
|
||||||
|
}
|
||||||
|
if (alreadyGenerating.length) {
|
||||||
|
return `分镜 ${alreadyGenerating.join(", ")} 正在生成中,请稍候`;
|
||||||
|
}
|
||||||
|
return "没有需要生成的分镜";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为正在生成
|
||||||
|
for (const id of toGenerate) {
|
||||||
|
this.generatingShots.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知前端开始生成
|
||||||
|
this.emit("shotImageGenerateStart", { shotIds: toGenerate });
|
||||||
|
this.log("开始生成分镜图", `分镜: [${toGenerate.join(", ")}]`);
|
||||||
|
|
||||||
|
// 异步执行图片生成(不阻塞 Agent 流程)
|
||||||
|
this.executeShotImageGeneration(toGenerate).catch((err) => {
|
||||||
|
this.log("分镜图生成错误", err.message);
|
||||||
|
this.emit("shotImageGenerateError", { shotIds: toGenerate, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = `已开始为分镜 ${toGenerate.join(", ")} 生成分镜图,生成过程在后台进行`;
|
||||||
|
if (alreadyGenerating.length) {
|
||||||
|
result += `;分镜 ${alreadyGenerating.join(", ")} 正在生成中`;
|
||||||
|
}
|
||||||
|
if (notFound.length) {
|
||||||
|
result += `;分镜 ${notFound.join(", ")} 不存在`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generateShotImage",
|
||||||
|
description:
|
||||||
|
"为指定分镜生成分镜图。每个分镜会根据其所有提示词生成一张完整宫格图,然后自动分割为单格图片。通过分镜ID指定,不需要指定具体格子,整个分镜是一个完整的生成单元",
|
||||||
|
schema: z.object({
|
||||||
|
shotIds: z.array(z.number()).describe("要生成分镜图的分镜ID数组"),
|
||||||
|
}),
|
||||||
|
verboseParsingErrors: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行分镜图生成的具体逻辑(异步并发)
|
||||||
|
* 每个分镜包含多个镜头,所有镜头的提示词合并生成一张宫格图,再分割为单张镜头图片
|
||||||
|
*/
|
||||||
|
async executeShotImageGeneration(shotIds: number[]): Promise<void> {
|
||||||
|
await Promise.all(shotIds.map((shotId) => this.generateSingleShotImage(shotId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单个分镜的图片
|
||||||
|
*/
|
||||||
|
private async generateSingleShotImage(shotId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const shot = this.shots.find((f) => f.id === shotId);
|
||||||
|
if (!shot) return;
|
||||||
|
|
||||||
|
// 提取所有镜头的有效提示词
|
||||||
|
const prompts: string[] = shot.cells.map((c) => c.prompt).filter((p): p is string => Boolean(p));
|
||||||
|
|
||||||
|
if (prompts.length === 0) {
|
||||||
|
this.log("跳过分镜图生成", `分镜 ${shotId} 没有有效的镜头提示词`);
|
||||||
|
this.generatingShots.delete(shotId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知前端正在生成该分镜
|
||||||
|
this.emit("shotImageGenerateProgress", { shotId, status: "generating", message: "正在调用 AI 生成宫格图片" });
|
||||||
|
|
||||||
|
// 根据所有镜头提示词生成宫格图片
|
||||||
|
const gridImage = await generateImageTool(
|
||||||
|
prompts.map((p) => ({ prompt: p })),
|
||||||
|
this.scriptId,
|
||||||
|
this.projectId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 通知前端正在分割图片
|
||||||
|
this.emit("shotImageGenerateProgress", { shotId, status: "splitting", message: "正在分割宫格图片为单张镜头图" });
|
||||||
|
|
||||||
|
// 分割宫格图片为单张镜头图片
|
||||||
|
const imageBuffers = await imageSplitting(gridImage, prompts.length);
|
||||||
|
|
||||||
|
// 通知前端正在保存图片
|
||||||
|
this.emit("shotImageGenerateProgress", { shotId, status: "saving", message: `正在保存 ${imageBuffers.length} 张镜头图片` });
|
||||||
|
|
||||||
|
// 保存分割后的镜头图片到 OSS,并获取文件路径
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const imagePaths: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < imageBuffers.length; i++) {
|
||||||
|
const fileName = `${this.projectId}/chat/${this.scriptId}/storyboard/shot_${shotId}_take_${i}_${timestamp}.png`;
|
||||||
|
await u.oss.writeFile(fileName, imageBuffers[i]);
|
||||||
|
const imageUrl = await u.oss.getFileUrl(fileName);
|
||||||
|
imagePaths.push(imageUrl);
|
||||||
|
|
||||||
|
// 每保存一张镜头图片通知进度
|
||||||
|
this.emit("shotImageGenerateProgress", {
|
||||||
|
shotId,
|
||||||
|
status: "saving",
|
||||||
|
message: `已保存 ${i + 1}/${imageBuffers.length} 张镜头图片`,
|
||||||
|
progress: Math.round(((i + 1) / imageBuffers.length) * 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新每个镜头的 src 字段
|
||||||
|
shot.cells = shot.cells.map((cell, i) => ({
|
||||||
|
id: u.uuid(),
|
||||||
|
...cell,
|
||||||
|
src: imagePaths[i] || cell.src,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 生成完成后更新状态
|
||||||
|
this.generatingShots.delete(shotId);
|
||||||
|
this.emit("shotImageGenerateComplete", { shotId, shot, imagePaths });
|
||||||
|
this.emit("shotsUpdated", this.shots);
|
||||||
|
this.log("分镜图生成完成", `分镜 ${shotId},共 ${imagePaths.length} 张镜头图片`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.generatingShots.delete(shotId);
|
||||||
|
this.emit("shotImageGenerateError", { shotId, error: err.message });
|
||||||
|
this.log("分镜图生成失败", `分镜 ${shotId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 公共访问器 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前片段数据
|
||||||
|
*/
|
||||||
|
getSegmentsData(): Segment[] {
|
||||||
|
return this.segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前分镜数据
|
||||||
|
*/
|
||||||
|
getShotsData(): Shot[] {
|
||||||
|
return this.shots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 上下文构建 ====================
|
||||||
|
|
||||||
|
private async buildEnvironmentContext(): Promise<string> {
|
||||||
|
const projectInfo = await u.db("t_project").where({ id: this.projectId }).first();
|
||||||
|
|
||||||
|
const row = await u.db("t_outline").where({ id: this.scriptId, projectId: this.projectId }).first();
|
||||||
|
const outline: any | null = row?.data ? JSON.parse(row.data) : null;
|
||||||
|
|
||||||
|
// 分类提取资源名称
|
||||||
|
const characters = outline?.characters?.map((i: any) => i.name) ?? [];
|
||||||
|
const props = outline?.props?.map((i: any) => i.name) ?? [];
|
||||||
|
const scenes = outline?.scenes?.map((i: any) => i.name) ?? [];
|
||||||
|
|
||||||
|
const assetList =
|
||||||
|
[
|
||||||
|
characters.length ? `【角色】${characters.join("、")}` : "",
|
||||||
|
props.length ? `【道具】${props.join("、")}` : "",
|
||||||
|
scenes.length ? `【场景】${scenes.join("、")}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n") || "无";
|
||||||
|
|
||||||
|
return `<环境信息>
|
||||||
|
项目ID: ${this.projectId}
|
||||||
|
系统时间: ${new Date().toLocaleString()}
|
||||||
|
|
||||||
|
项目名称: ${projectInfo?.name || "未知"}
|
||||||
|
项目简介: ${projectInfo?.intro || "无"}
|
||||||
|
类型: ${projectInfo?.type || "未知"}
|
||||||
|
风格: ${projectInfo?.artStyle || "未知"}
|
||||||
|
视频比例: ${projectInfo?.videoRatio || "未知"}
|
||||||
|
|
||||||
|
资产列表:
|
||||||
|
${assetList}
|
||||||
|
|
||||||
|
</环境信息>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConversationHistory(): string {
|
||||||
|
if (!this.history.length) return "无对话历史";
|
||||||
|
return this.history.map(([role, content]) => `${role}: ${content}`).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildFullContext(task: string): Promise<string> {
|
||||||
|
const env = await this.buildEnvironmentContext();
|
||||||
|
const history = this.buildConversationHistory();
|
||||||
|
|
||||||
|
return `${env}
|
||||||
|
|
||||||
|
<对话历史>
|
||||||
|
${history}
|
||||||
|
</对话历史>
|
||||||
|
|
||||||
|
<当前任务>
|
||||||
|
${task}
|
||||||
|
</当前任务>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Sub-Agent ====================
|
||||||
|
|
||||||
|
private createModel() {
|
||||||
|
return openAI({
|
||||||
|
modelName: this.modelName,
|
||||||
|
configuration: { apiKey: this.apiKey, baseURL: this.baseURL },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取不同 Sub-Agent 可用的工具
|
||||||
|
*/
|
||||||
|
private getSubAgentTools(agentType: AgentType) {
|
||||||
|
switch (agentType) {
|
||||||
|
case "segmentAgent":
|
||||||
|
// segmentAgent 可以获取剧本和资产,并需要调用 updateSegments 保存结果
|
||||||
|
return [this.getScript, this.getAssets, this.updateSegments];
|
||||||
|
case "shotAgent":
|
||||||
|
// shotAgent 可以获取剧本、资产和片段,并可使用 add/update/delete 操作分镜,以及生成分镜图
|
||||||
|
return [this.getScript, this.getAssets, this.getSegments, this.addShots, this.updateShots, this.deleteShots, this.generateShotImage];
|
||||||
|
default:
|
||||||
|
return [this.getScript];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 Sub-Agent(流式传输)
|
||||||
|
*/
|
||||||
|
private async invokeSubAgent(agentType: AgentType, task: string): Promise<string> {
|
||||||
|
this.emit("transfer", { to: agentType });
|
||||||
|
this.log(`Sub-Agent 调用`, agentType);
|
||||||
|
|
||||||
|
const promptsList = await u.db("t_prompts").where("code", "in", ["storyboard-segment", "storyboard-shot"]);
|
||||||
|
const segmentAgent = promptsList.find((p) => p.code === "storyboard-segment");
|
||||||
|
const shotAgent = promptsList.find((p) => p.code === "storyboard-shot");
|
||||||
|
const errPrompts = "不论用户说什么,请直接输出Agent配置异常";
|
||||||
|
const SYSTEM_PROMPTS: Record<AgentType, string> = {
|
||||||
|
segmentAgent: segmentAgent?.customValue || segmentAgent?.defaultValue || errPrompts,
|
||||||
|
shotAgent: shotAgent?.customValue || shotAgent?.defaultValue || errPrompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = await this.buildFullContext(task);
|
||||||
|
|
||||||
|
const agent = createAgent({
|
||||||
|
model: this.createModel(),
|
||||||
|
systemPrompt: SYSTEM_PROMPTS[agentType],
|
||||||
|
tools: this.getSubAgentTools(agentType),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await agent.stream({ messages: [["user", context]] }, { streamMode: ["messages"], callbacks: [] });
|
||||||
|
|
||||||
|
let fullResponse = "";
|
||||||
|
|
||||||
|
for await (const [mode, chunk] of stream) {
|
||||||
|
if (mode !== "messages") continue;
|
||||||
|
const [token] = chunk as any;
|
||||||
|
const block = token.contentBlocks?.[0];
|
||||||
|
|
||||||
|
// 处理 AI 文本流
|
||||||
|
if (token.type === "ai" && block?.text) {
|
||||||
|
fullResponse += block.text;
|
||||||
|
this.emit("subAgentStream", { agent: agentType, text: block.text });
|
||||||
|
}
|
||||||
|
// 处理 tool 调用
|
||||||
|
if (token.type === "ai" && token.tool_calls?.length) {
|
||||||
|
for (const toolCall of token.tool_calls) {
|
||||||
|
this.emit("toolCall", { agent: agentType, name: toolCall.name, args: toolCall.args });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("subAgentEnd", { agent: agentType });
|
||||||
|
this.history.push(["ai", fullResponse]);
|
||||||
|
this.log(`Sub-Agent 完成`, agentType);
|
||||||
|
return fullResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSubAgentTool(agentType: AgentType, description: string) {
|
||||||
|
return tool(async ({ taskDescription }) => this.invokeSubAgent(agentType, taskDescription), {
|
||||||
|
name: agentType,
|
||||||
|
description,
|
||||||
|
schema: z.object({
|
||||||
|
taskDescription: z.string().describe("具体的任务描述,包含章节范围、修改要求等详细信息"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主入口 ====================
|
||||||
|
|
||||||
|
private getAllTools() {
|
||||||
|
return [
|
||||||
|
this.createSubAgentTool(
|
||||||
|
"segmentAgent",
|
||||||
|
"调用片段师。负责根据剧本生成片段,会自行调用 getScript 获取剧本内容,并调用 updateSegments 保存片段结果。",
|
||||||
|
),
|
||||||
|
this.createSubAgentTool(
|
||||||
|
"shotAgent",
|
||||||
|
"调用分镜师。负责根据片段生成分镜提示词,会自行调用 getSegments 获取片段数据,并调用 addShots/updateShots 保存分镜结果。",
|
||||||
|
),
|
||||||
|
// this.createSubAgentTool("director", "调用导演。负责审核故事线和大纲,会自行调用 updateOutline 或 saveStoryline 进行修改。"),
|
||||||
|
this.getScript,
|
||||||
|
this.getSegments,
|
||||||
|
this.generateShotImage,
|
||||||
|
...this.getSubAgentTools("segmentAgent"),
|
||||||
|
...this.getSubAgentTools("shotAgent"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(msg: string): Promise<string> {
|
||||||
|
console.log("模型名称:", this.modelName);
|
||||||
|
this.history.push(["user", msg]);
|
||||||
|
|
||||||
|
const envContext = await this.buildEnvironmentContext();
|
||||||
|
|
||||||
|
const prompts = await u.db("t_prompts").where("code", "storyboard-main").first();
|
||||||
|
|
||||||
|
const mainPrompts = prompts?.customValue || prompts?.defaultValue || "不论用户说什么,请直接输出Agent配置异常";
|
||||||
|
|
||||||
|
const mainAgent = createAgent({
|
||||||
|
model: this.createModel(),
|
||||||
|
tools: this.getAllTools(),
|
||||||
|
systemPrompt: `${envContext}\n${mainPrompts}`,
|
||||||
|
});
|
||||||
|
const stream = await mainAgent.stream({ messages: this.history }, { streamMode: ["messages"], callbacks: [] });
|
||||||
|
|
||||||
|
let fullResponse = "";
|
||||||
|
|
||||||
|
for await (const [mode, chunk] of stream) {
|
||||||
|
if (mode !== "messages") continue;
|
||||||
|
const [token] = chunk as any;
|
||||||
|
const block = token.contentBlocks?.[0];
|
||||||
|
// 处理 AI 文本流
|
||||||
|
if (token.type === "ai" && block?.text) {
|
||||||
|
fullResponse += block.text;
|
||||||
|
this.emit("data", block.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 tool 调用
|
||||||
|
if (token.type === "ai" && token.tool_calls?.length) {
|
||||||
|
for (const toolCall of token.tool_calls) {
|
||||||
|
this.emit("toolCall", { agent: "main", name: toolCall.name, args: toolCall.args });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.history.push(["assistant", fullResponse]);
|
||||||
|
this.emit("response", fullResponse);
|
||||||
|
|
||||||
|
return fullResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/app.ts
Normal file
98
src/app.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import "./err";
|
||||||
|
import "./env";
|
||||||
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
|
import expressWs from "express-ws";
|
||||||
|
import logger from "morgan";
|
||||||
|
import cors from "cors";
|
||||||
|
import buildRoute from "@/core";
|
||||||
|
import fs from "fs";
|
||||||
|
import router from "@/router";
|
||||||
|
import path from "path";
|
||||||
|
import u from "@/utils";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
let server: ReturnType<typeof app.listen> | null = null;
|
||||||
|
|
||||||
|
export default async function startServe() {
|
||||||
|
if (process.env.NODE_ENV == "dev") await buildRoute();
|
||||||
|
|
||||||
|
expressWs(app);
|
||||||
|
|
||||||
|
app.use(logger("dev"));
|
||||||
|
app.use(cors({ origin: "*" }));
|
||||||
|
app.use(express.json({ limit: "100mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "100mb" }));
|
||||||
|
|
||||||
|
let rootDir: string;
|
||||||
|
if (typeof process.versions?.electron !== "undefined") {
|
||||||
|
const { app } = require("electron");
|
||||||
|
const userDataDir: string = app.getPath("userData");
|
||||||
|
rootDir = path.join(userDataDir, "uploads");
|
||||||
|
} else {
|
||||||
|
rootDir = path.join(process.cwd(), "uploads");
|
||||||
|
}
|
||||||
|
// 确保 uploads 目录存在
|
||||||
|
if (!fs.existsSync(rootDir)) {
|
||||||
|
fs.mkdirSync(rootDir, { recursive: true });
|
||||||
|
}
|
||||||
|
console.log("文件目录:", rootDir);
|
||||||
|
|
||||||
|
app.use(express.static(rootDir));
|
||||||
|
|
||||||
|
app.use(async (req, res, next) => {
|
||||||
|
const setting = await u.db("t_setting").where("id", 1).select("tokenKey").first();
|
||||||
|
if (!setting) return res.status(500).send({ message: "服务器未配置,请联系管理员" });
|
||||||
|
const { tokenKey } = setting;
|
||||||
|
const token = req.headers.authorization?.replace("Bearer ", "");
|
||||||
|
if (req.path == "/other/login") return next();
|
||||||
|
if (!token) return res.status(401).send({ message: "未提供token" });
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, tokenKey as string);
|
||||||
|
(req as any).user = decoded;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).send({ message: "无效的token" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await router(app);
|
||||||
|
|
||||||
|
// 404 处理
|
||||||
|
app.use((_, res, next: NextFunction) => {
|
||||||
|
return res.status(404).send({ message: "Not Found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
app.use((err: any, _: Request, res: Response, __: NextFunction) => {
|
||||||
|
res.locals.message = err.message;
|
||||||
|
res.locals.error = err;
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).send(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT || "60000");
|
||||||
|
server = app.listen(port, async () => {
|
||||||
|
const address = server?.address();
|
||||||
|
const realPort = typeof address === "string" ? address : address?.port;
|
||||||
|
console.log(`[服务启动成功]: http://localhost:${realPort}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持await关闭
|
||||||
|
export function closeServe(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (server) {
|
||||||
|
server.close((err?: Error) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
console.log("[服务已关闭]");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isElectron = typeof process.versions?.electron !== "undefined";
|
||||||
|
if (!isElectron) startServe();
|
||||||
60
src/core.ts
Normal file
60
src/core.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import fg from "fast-glob";
|
||||||
|
import path from "path";
|
||||||
|
import { readFile, writeFile } from "fs/promises";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
function fileNameToRoutePath(fileName: string): string {
|
||||||
|
let routePath = fileName.replace(/\.(ts)$/, "");
|
||||||
|
routePath = routePath.split(path.sep).join("/");
|
||||||
|
routePath = routePath.replace(/\[([^\]]+)\]/g, (_, p1: string) => (p1.startsWith("...") ? "*" : `:${p1}`));
|
||||||
|
if (routePath === "index") return "/";
|
||||||
|
routePath = routePath.replace(/\/index$/, "");
|
||||||
|
routePath = "/" + routePath.replace(/\/+/g, "/").replace(/\/$/, "");
|
||||||
|
return routePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteModulePair = { routePath: string; varName: string; entry: string };
|
||||||
|
|
||||||
|
export default async function generateRouter(): Promise<void> {
|
||||||
|
// glob 得到 entries
|
||||||
|
let entries: string[] = await fg(["src/routes/**/*.ts"]);
|
||||||
|
// 排序
|
||||||
|
entries = entries.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const importLines: string[] = [];
|
||||||
|
const routeModulePairs: RouteModulePair[] = [];
|
||||||
|
|
||||||
|
entries.forEach((entry: string, i: number) => {
|
||||||
|
const varName = `route${i + 1}`;
|
||||||
|
let importPath = path.relative("src", entry).replace(/\\/g, "/");
|
||||||
|
if (!importPath.startsWith(".")) importPath = "./" + importPath;
|
||||||
|
importPath = importPath.replace(/\.ts$/, "");
|
||||||
|
importLines.push(`import ${varName} from "${importPath}";`);
|
||||||
|
const routeKey = path.relative("src/routes", entry).replace(/\\/g, "/");
|
||||||
|
const routePath = fileNameToRoutePath(routeKey);
|
||||||
|
routeModulePairs.push({ routePath, varName, entry });
|
||||||
|
});
|
||||||
|
const routerData = JSON.stringify(routeModulePairs.map(({ routePath, varName }) => ({ routePath, varName })));
|
||||||
|
const hash = crypto.createHash("md5").update(routerData).digest("hex");
|
||||||
|
|
||||||
|
let content = `// @routes-hash ${hash}\nimport { Express } from "express";\n\n`;
|
||||||
|
content += `${importLines.join("\n")}\n\n`;
|
||||||
|
content += `export default async (app: Express) => {\n`;
|
||||||
|
for (const { routePath, varName } of routeModulePairs) {
|
||||||
|
content += ` app.use("${routePath}", ${varName});\n`;
|
||||||
|
}
|
||||||
|
content += `}\n`;
|
||||||
|
|
||||||
|
let needWrite = true;
|
||||||
|
try {
|
||||||
|
const current = await readFile("src/router.ts", "utf8");
|
||||||
|
const match = current.match(/^\/\/\s*@routes-hash\s*([a-z0-9]+)\n/);
|
||||||
|
const currentHash = match ? match[1] : null;
|
||||||
|
if (currentHash === hash) {
|
||||||
|
needWrite = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
needWrite = true;
|
||||||
|
}
|
||||||
|
if (needWrite) await writeFile("src/router.ts", content, "utf8");
|
||||||
|
}
|
||||||
31
src/env.ts
Normal file
31
src/env.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
|
||||||
|
function loadDotenvESM(envPath = ".env.local") {
|
||||||
|
// 尝试从 userData 目录读取环境变量,如果不存在则使用当前目录
|
||||||
|
let finalPath: string;
|
||||||
|
|
||||||
|
if (typeof process.versions?.electron !== "undefined") {
|
||||||
|
const { app } = require("electron");
|
||||||
|
finalPath = app.getPath("userData");
|
||||||
|
// 如果 userData 目录中不存在,尝试使用当前目录
|
||||||
|
if (!existsSync(finalPath)) {
|
||||||
|
finalPath = envPath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalPath = envPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(finalPath)) {
|
||||||
|
console.log(`[环境变量]: ${envPath} 文件不存在`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = readFileSync(finalPath, "utf8");
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx > 0) process.env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
||||||
|
}
|
||||||
|
console.log(`[环境变量]: ${finalPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV == "dev") loadDotenvESM(".env.local");
|
||||||
10
src/err.ts
Normal file
10
src/err.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// 处理未捕获的 Promise 拒绝
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('[未处理的 Promise 拒绝]:', reason);
|
||||||
|
console.error('Promise:', promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理未捕获的异常
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('[未捕获的异常]:', error);
|
||||||
|
});
|
||||||
476
src/lib/initDB.ts
Normal file
476
src/lib/initDB.ts
Normal file
File diff suppressed because one or more lines are too long
23
src/lib/responseFormat.ts
Normal file
23
src/lib/responseFormat.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface ApiResponse {
|
||||||
|
code: number;
|
||||||
|
data: any;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功回调
|
||||||
|
export function success<T>(data: T | null = null, message: string = "成功"): ApiResponse {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
data,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端错误响应
|
||||||
|
export function error<T>(message: string = "", data: T | null = null): ApiResponse {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
data,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/middleware/middleware.ts
Normal file
24
src/middleware/middleware.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z, ZodTypeAny } from "zod";
|
||||||
|
|
||||||
|
import { zhCN } from "zod/locales";
|
||||||
|
|
||||||
|
z.config(zhCN());
|
||||||
|
|
||||||
|
export function validateFields(
|
||||||
|
shape: Record<string, ZodTypeAny>,
|
||||||
|
source: "body" | "query" | "params" = "body", // 默认校验 body
|
||||||
|
) {
|
||||||
|
const schema = z.object(shape);
|
||||||
|
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const data = req[source];
|
||||||
|
const parseResult = schema.safeParse(data);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
const errors = parseResult.error.issues.map((issue) => `字段 ${issue.path.join(".")} ${issue.message}`);
|
||||||
|
console.error(errors);
|
||||||
|
return res.status(400).json({ message: "参数错误", errors });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
140
src/router.ts
Normal file
140
src/router.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// @routes-hash 72a19d0a42620de7690a4335be827821
|
||||||
|
import { Express } from "express";
|
||||||
|
|
||||||
|
import route1 from "./routes/assets/addAssets";
|
||||||
|
import route2 from "./routes/assets/delAssets";
|
||||||
|
import route3 from "./routes/assets/generateAssets";
|
||||||
|
import route4 from "./routes/assets/getAssets";
|
||||||
|
import route5 from "./routes/assets/getImage";
|
||||||
|
import route6 from "./routes/assets/getStoryboard";
|
||||||
|
import route7 from "./routes/assets/polishPrompt";
|
||||||
|
import route8 from "./routes/assets/saveAssets";
|
||||||
|
import route9 from "./routes/assets/updateAssets";
|
||||||
|
import route10 from "./routes/index/index";
|
||||||
|
import route11 from "./routes/novel/addNovel";
|
||||||
|
import route12 from "./routes/novel/delNovel";
|
||||||
|
import route13 from "./routes/novel/getNovel";
|
||||||
|
import route14 from "./routes/novel/updateNovel";
|
||||||
|
import route15 from "./routes/other/clearDatabase";
|
||||||
|
import route16 from "./routes/other/deleteAllData";
|
||||||
|
import route17 from "./routes/other/getCaptcha";
|
||||||
|
import route18 from "./routes/other/login";
|
||||||
|
import route19 from "./routes/outline/addOutline";
|
||||||
|
import route20 from "./routes/outline/agentsOutline";
|
||||||
|
import route21 from "./routes/outline/delOutline";
|
||||||
|
import route22 from "./routes/outline/getHistory";
|
||||||
|
import route23 from "./routes/outline/getOutline";
|
||||||
|
import route24 from "./routes/outline/getPartScript";
|
||||||
|
import route25 from "./routes/outline/getStoryline";
|
||||||
|
import route26 from "./routes/outline/setHistory";
|
||||||
|
import route27 from "./routes/outline/updateOutline";
|
||||||
|
import route28 from "./routes/outline/updateScript";
|
||||||
|
import route29 from "./routes/outline/updateStoryline";
|
||||||
|
import route30 from "./routes/project/addProject";
|
||||||
|
import route31 from "./routes/project/delProject";
|
||||||
|
import route32 from "./routes/project/getProject";
|
||||||
|
import route33 from "./routes/project/getProjectCount";
|
||||||
|
import route34 from "./routes/project/getSingleProject";
|
||||||
|
import route35 from "./routes/project/updateProject";
|
||||||
|
import route36 from "./routes/prompt/getPrompts";
|
||||||
|
import route37 from "./routes/prompt/updatePrompt";
|
||||||
|
import route38 from "./routes/script/generateScriptApi";
|
||||||
|
import route39 from "./routes/script/generateScriptSave";
|
||||||
|
import route40 from "./routes/script/geScriptApi";
|
||||||
|
import route41 from "./routes/setting/getSetting";
|
||||||
|
import route42 from "./routes/setting/updateSetting";
|
||||||
|
import route43 from "./routes/storyboard/batchSuperScoreImage";
|
||||||
|
import route44 from "./routes/storyboard/chatStoryboard";
|
||||||
|
import route45 from "./routes/storyboard/generateShotImage";
|
||||||
|
import route46 from "./routes/storyboard/generateStoryboardApi";
|
||||||
|
import route47 from "./routes/storyboard/generateVideoPrompt";
|
||||||
|
import route48 from "./routes/storyboard/getStoryboard";
|
||||||
|
import route49 from "./routes/storyboard/keepStoryboard";
|
||||||
|
import route50 from "./routes/storyboard/saveStoryboard";
|
||||||
|
import route51 from "./routes/storyboard/uploadImage";
|
||||||
|
import route52 from "./routes/task/getTaskApi";
|
||||||
|
import route53 from "./routes/task/taskDetails";
|
||||||
|
import route54 from "./routes/user/getUser";
|
||||||
|
import route55 from "./routes/video/addVideo";
|
||||||
|
import route56 from "./routes/video/addVideoConfig";
|
||||||
|
import route57 from "./routes/video/deleteVideoConfig";
|
||||||
|
import route58 from "./routes/video/generatePrompt";
|
||||||
|
import route59 from "./routes/video/generateVideo";
|
||||||
|
import route60 from "./routes/video/getManufacturer";
|
||||||
|
import route61 from "./routes/video/getVideo";
|
||||||
|
import route62 from "./routes/video/getVideoConfigs";
|
||||||
|
import route63 from "./routes/video/getVideoModel";
|
||||||
|
import route64 from "./routes/video/getVideoStoryboards";
|
||||||
|
import route65 from "./routes/video/reviseVideoStoryboards";
|
||||||
|
import route66 from "./routes/video/saveVideo";
|
||||||
|
import route67 from "./routes/video/upDateVideoConfig";
|
||||||
|
|
||||||
|
export default async (app: Express) => {
|
||||||
|
app.use("/assets/addAssets", route1);
|
||||||
|
app.use("/assets/delAssets", route2);
|
||||||
|
app.use("/assets/generateAssets", route3);
|
||||||
|
app.use("/assets/getAssets", route4);
|
||||||
|
app.use("/assets/getImage", route5);
|
||||||
|
app.use("/assets/getStoryboard", route6);
|
||||||
|
app.use("/assets/polishPrompt", route7);
|
||||||
|
app.use("/assets/saveAssets", route8);
|
||||||
|
app.use("/assets/updateAssets", route9);
|
||||||
|
app.use("/index", route10);
|
||||||
|
app.use("/novel/addNovel", route11);
|
||||||
|
app.use("/novel/delNovel", route12);
|
||||||
|
app.use("/novel/getNovel", route13);
|
||||||
|
app.use("/novel/updateNovel", route14);
|
||||||
|
app.use("/other/clearDatabase", route15);
|
||||||
|
app.use("/other/deleteAllData", route16);
|
||||||
|
app.use("/other/getCaptcha", route17);
|
||||||
|
app.use("/other/login", route18);
|
||||||
|
app.use("/outline/addOutline", route19);
|
||||||
|
app.use("/outline/agentsOutline", route20);
|
||||||
|
app.use("/outline/delOutline", route21);
|
||||||
|
app.use("/outline/getHistory", route22);
|
||||||
|
app.use("/outline/getOutline", route23);
|
||||||
|
app.use("/outline/getPartScript", route24);
|
||||||
|
app.use("/outline/getStoryline", route25);
|
||||||
|
app.use("/outline/setHistory", route26);
|
||||||
|
app.use("/outline/updateOutline", route27);
|
||||||
|
app.use("/outline/updateScript", route28);
|
||||||
|
app.use("/outline/updateStoryline", route29);
|
||||||
|
app.use("/project/addProject", route30);
|
||||||
|
app.use("/project/delProject", route31);
|
||||||
|
app.use("/project/getProject", route32);
|
||||||
|
app.use("/project/getProjectCount", route33);
|
||||||
|
app.use("/project/getSingleProject", route34);
|
||||||
|
app.use("/project/updateProject", route35);
|
||||||
|
app.use("/prompt/getPrompts", route36);
|
||||||
|
app.use("/prompt/updatePrompt", route37);
|
||||||
|
app.use("/script/generateScriptApi", route38);
|
||||||
|
app.use("/script/generateScriptSave", route39);
|
||||||
|
app.use("/script/geScriptApi", route40);
|
||||||
|
app.use("/setting/getSetting", route41);
|
||||||
|
app.use("/setting/updateSetting", route42);
|
||||||
|
app.use("/storyboard/batchSuperScoreImage", route43);
|
||||||
|
app.use("/storyboard/chatStoryboard", route44);
|
||||||
|
app.use("/storyboard/generateShotImage", route45);
|
||||||
|
app.use("/storyboard/generateStoryboardApi", route46);
|
||||||
|
app.use("/storyboard/generateVideoPrompt", route47);
|
||||||
|
app.use("/storyboard/getStoryboard", route48);
|
||||||
|
app.use("/storyboard/keepStoryboard", route49);
|
||||||
|
app.use("/storyboard/saveStoryboard", route50);
|
||||||
|
app.use("/storyboard/uploadImage", route51);
|
||||||
|
app.use("/task/getTaskApi", route52);
|
||||||
|
app.use("/task/taskDetails", route53);
|
||||||
|
app.use("/user/getUser", route54);
|
||||||
|
app.use("/video/addVideo", route55);
|
||||||
|
app.use("/video/addVideoConfig", route56);
|
||||||
|
app.use("/video/deleteVideoConfig", route57);
|
||||||
|
app.use("/video/generatePrompt", route58);
|
||||||
|
app.use("/video/generateVideo", route59);
|
||||||
|
app.use("/video/getManufacturer", route60);
|
||||||
|
app.use("/video/getVideo", route61);
|
||||||
|
app.use("/video/getVideoConfigs", route62);
|
||||||
|
app.use("/video/getVideoModel", route63);
|
||||||
|
app.use("/video/getVideoStoryboards", route64);
|
||||||
|
app.use("/video/reviseVideoStoryboards", route65);
|
||||||
|
app.use("/video/saveVideo", route66);
|
||||||
|
app.use("/video/upDateVideoConfig", route67);
|
||||||
|
}
|
||||||
37
src/routes/assets/addAssets.ts
Normal file
37
src/routes/assets/addAssets.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 新增资产
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
scriptId: z.number().optional().nullable(),
|
||||||
|
name: z.string(),
|
||||||
|
intro: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
remark: z.string().optional().nullable(),
|
||||||
|
episode: z.string().optional().nullable(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, name, intro, type, prompt, remark, episode, scriptId } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_assets").insert({
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
intro,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
remark,
|
||||||
|
episode,
|
||||||
|
scriptId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "新增资产成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
21
src/routes/assets/delAssets.ts
Normal file
21
src/routes/assets/delAssets.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除资产
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_assets").where("id", id).del();
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "删除资产成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
207
src/routes/assets/generateAssets.ts
Normal file
207
src/routes/assets/generateAssets.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import sharp from "sharp";
|
||||||
|
const router = express.Router();
|
||||||
|
interface OutlineItem {
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutlineData {
|
||||||
|
chapterRange: number[];
|
||||||
|
characters?: OutlineItem[];
|
||||||
|
props?: OutlineItem[];
|
||||||
|
scenes?: OutlineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItemType = "characters" | "props" | "scenes";
|
||||||
|
|
||||||
|
interface ResultItem {
|
||||||
|
type: ItemType;
|
||||||
|
name: string;
|
||||||
|
chapterRange: number[];
|
||||||
|
}
|
||||||
|
// 生成资产图片
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
type: z.enum(["role", "scene", "props", "storyboard"]),
|
||||||
|
projectId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
base64: z.string().optional().nullable(),
|
||||||
|
prompt: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, type, projectId, base64, prompt, name } = req.body;
|
||||||
|
|
||||||
|
//获取风格
|
||||||
|
const project = await u.db("t_project").where("id", projectId).select("artStyle", "type", "intro").first();
|
||||||
|
if (!project) return res.status(500).send(success({ message: "项目为空" }));
|
||||||
|
|
||||||
|
const promptsList = await u
|
||||||
|
.db("t_prompts")
|
||||||
|
.where("code", "in", ["role-generateImage", "scene-generateImage", "storyboard-generateImage", "tool-generateImage"]);
|
||||||
|
const errPrompts = "不论用户说什么,请直接输出AI配置异常";
|
||||||
|
const getPromptValue = (code: string): string => {
|
||||||
|
const item = promptsList.find((p) => p.code === code);
|
||||||
|
return item?.customValue ?? item?.defaultValue ?? errPrompts;
|
||||||
|
};
|
||||||
|
const role = getPromptValue("role-generateImage");
|
||||||
|
const scene = getPromptValue("scene-generateImage");
|
||||||
|
const tool = getPromptValue("tool-generateImage");
|
||||||
|
const storyboard = getPromptValue("storyboard-generateImage");
|
||||||
|
|
||||||
|
let systemPrompt = "";
|
||||||
|
let userPrompt = "";
|
||||||
|
if (type == "role") {
|
||||||
|
systemPrompt = role;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成角色标准四视图:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 画风风格: ${project?.artStyle || "未指定"}
|
||||||
|
|
||||||
|
**角色设定:**
|
||||||
|
- 名称:${name},
|
||||||
|
- 提示词:${prompt},
|
||||||
|
|
||||||
|
请严格按照系统规范生成人物角色四视图。
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type == "scene") {
|
||||||
|
systemPrompt = scene;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成标准场景图:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 画风风格: ${project?.artStyle || "未指定"}
|
||||||
|
|
||||||
|
**场景设定:**
|
||||||
|
- 名称:${name},
|
||||||
|
- 提示词:${prompt},
|
||||||
|
|
||||||
|
请严格按照系统规范生成标准场景图。
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type == "props") {
|
||||||
|
systemPrompt = tool;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成标准道具图:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 画风风格: ${project?.artStyle || "未指定"}
|
||||||
|
|
||||||
|
**道具设定:**
|
||||||
|
- 名称:${name},
|
||||||
|
- 提示词:${prompt},
|
||||||
|
|
||||||
|
请严格按照系统规范生成标准道具图。
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type == "storyboard") {
|
||||||
|
systemPrompt = storyboard;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成标准分镜图:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 画风风格: ${project?.artStyle || "未指定"}
|
||||||
|
|
||||||
|
**分镜设定:**
|
||||||
|
- 名称:${name},
|
||||||
|
- 提示词:${prompt},
|
||||||
|
|
||||||
|
请严格按照系统规范生成标准分镜图。
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [imageId] = await u.db("t_image").insert({
|
||||||
|
state: "生成中",
|
||||||
|
assetsId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentStr = await u.ai.generateImage({
|
||||||
|
systemPrompt,
|
||||||
|
prompt: userPrompt,
|
||||||
|
imageBase64: base64 ? [base64] : [],
|
||||||
|
size: "2K",
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
});
|
||||||
|
|
||||||
|
let insertType;
|
||||||
|
const match = contentStr.match(/base64,([A-Za-z0-9+/=]+)/);
|
||||||
|
let buffer = Buffer.from(match && match.length >= 2 ? match[1]! : contentStr!, "base64");
|
||||||
|
if (type != "storyboard") {
|
||||||
|
//添加文本
|
||||||
|
// buffer = await imageAddText(name, buffer);
|
||||||
|
}
|
||||||
|
let imagePath;
|
||||||
|
if (type == "role") {
|
||||||
|
insertType = "角色";
|
||||||
|
imagePath = `/${projectId}/role/${uuidv4()}.jpg`;
|
||||||
|
}
|
||||||
|
if (type == "scene") {
|
||||||
|
insertType = "场景";
|
||||||
|
imagePath = `/${projectId}/scene/${uuidv4()}.jpg`;
|
||||||
|
}
|
||||||
|
if (type == "props") {
|
||||||
|
insertType = "道具";
|
||||||
|
imagePath = `/${projectId}/props/${uuidv4()}.jpg`;
|
||||||
|
}
|
||||||
|
await u.oss.writeFile(imagePath!, buffer);
|
||||||
|
|
||||||
|
await u.db("t_image").where("id", imageId).update({
|
||||||
|
state: "生成成功",
|
||||||
|
filePath: imagePath,
|
||||||
|
type: insertType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const path = await u.oss.getFileUrl(imagePath!);
|
||||||
|
|
||||||
|
// const state = await u.db("t_assets").where("id", id).select("state").first();
|
||||||
|
|
||||||
|
res.status(200).send(success({ path, assetsId: id }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
async function imageAddText(name: string, imageBuffer: Buffer) {
|
||||||
|
const meta = await sharp(imageBuffer).metadata();
|
||||||
|
const width = meta.width ?? 1000;
|
||||||
|
const height = meta.height ?? 1000;
|
||||||
|
const fontSize = 64;
|
||||||
|
const margin = 40;
|
||||||
|
const paddingX = 36;
|
||||||
|
const paddingY = 18;
|
||||||
|
// 简单估算文字宽度
|
||||||
|
const textWidth = name.length * fontSize * 0.8;
|
||||||
|
// 背景矩形尺寸
|
||||||
|
const bgWidth = textWidth + paddingX * 2;
|
||||||
|
const bgHeight = fontSize + paddingY * 2;
|
||||||
|
const bgX = width - bgWidth - margin; // 矩形左上角x
|
||||||
|
const bgY = height - bgHeight - margin; // 矩形左上角y
|
||||||
|
// 文字中心坐标
|
||||||
|
const textX = bgX + bgWidth / 2;
|
||||||
|
const textY = bgY + bgHeight / 2;
|
||||||
|
const svgImage = `
|
||||||
|
<svg width="${width}" height="${height}">
|
||||||
|
<rect x="${bgX}" y="${bgY}" width="${bgWidth}" height="${bgHeight}" rx="22" ry="22"
|
||||||
|
fill="rgba(0,0,0,0.6)" />
|
||||||
|
<text x="${textX}" y="${textY}"
|
||||||
|
fill="#fff"
|
||||||
|
font-size="${fontSize}"
|
||||||
|
font-family="Arial, 'Microsoft YaHei', sans-serif"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle">
|
||||||
|
${name}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const outputBuffer = await sharp(imageBuffer)
|
||||||
|
.composite([{ input: Buffer.from(svgImage), blend: "over" }])
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer();
|
||||||
|
return outputBuffer as Buffer<ArrayBuffer>;
|
||||||
|
}
|
||||||
30
src/routes/assets/getAssets.ts
Normal file
30
src/routes/assets/getAssets.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取资产
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
type: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, type } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_assets").where("projectId", projectId).where("type", type).select("*");
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
if (item.filePath) {
|
||||||
|
item.filePath = await u.oss.getFileUrl(item.filePath);
|
||||||
|
} else {
|
||||||
|
item.filePath = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
39
src/routes/assets/getImage.ts
Normal file
39
src/routes/assets/getImage.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取生成图片
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
assetsId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { assetsId } = req.body;
|
||||||
|
|
||||||
|
const assets = await u.db("t_assets").where("id", assetsId).select("id", "filePath", "scriptId", "type", "state").first();
|
||||||
|
|
||||||
|
const tempAssets = await u.db("t_image").where("assetsId", assetsId).select("id", "filePath", "assetsId", "type", "state");
|
||||||
|
|
||||||
|
for (const item of tempAssets) {
|
||||||
|
if (item.filePath) {
|
||||||
|
item.filePath = await u.oss.getFileUrl(item.filePath);
|
||||||
|
} else {
|
||||||
|
item.filePath = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
id: assets!.id,
|
||||||
|
state: assets!.state,
|
||||||
|
filePath: assets!.filePath ? await u.oss.getFileUrl(assets!.filePath) : "",
|
||||||
|
scriptId: assets!.scriptId,
|
||||||
|
tempAssets,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
},
|
||||||
|
);
|
||||||
21
src/routes/assets/getStoryboard.ts
Normal file
21
src/routes/assets/getStoryboard.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取资产分镜
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_script").where("projectId", projectId).select("name", "id").distinct("id", "name").orderBy("name", "asc");
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
},
|
||||||
|
);
|
||||||
220
src/routes/assets/polishPrompt.ts
Normal file
220
src/routes/assets/polishPrompt.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import * as zod from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
const jsonSchema = zod.object({
|
||||||
|
prompt: zod.string().describe("提示词"),
|
||||||
|
});
|
||||||
|
interface OutlineItem {
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutlineData {
|
||||||
|
chapterRange: number[];
|
||||||
|
characters?: OutlineItem[];
|
||||||
|
props?: OutlineItem[];
|
||||||
|
scenes?: OutlineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NovelChapter {
|
||||||
|
id: number;
|
||||||
|
reel: string;
|
||||||
|
chapter: string;
|
||||||
|
chapterData: string;
|
||||||
|
projectId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItemType = "characters" | "props" | "scenes";
|
||||||
|
|
||||||
|
interface ResultItem {
|
||||||
|
type: ItemType;
|
||||||
|
name: string;
|
||||||
|
chapterRange: number[];
|
||||||
|
}
|
||||||
|
function findItemByName(items: ResultItem[], name: string, type?: ItemType): ResultItem | undefined {
|
||||||
|
return items.find((item) => (!type || item.type === type) && item.name === name);
|
||||||
|
}
|
||||||
|
function mergeNovelText(novelData: NovelChapter[]): string {
|
||||||
|
if (!Array.isArray(novelData)) return "";
|
||||||
|
return novelData
|
||||||
|
.map((chap) => {
|
||||||
|
return `${chap.chapter.trim()}\n\n${chap.chapterData.trim().replace(/\r?\n/g, "\n")}\n`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
//润色提示词
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
assetsId: zod.number(),
|
||||||
|
projectId: zod.number(),
|
||||||
|
type: zod.string(),
|
||||||
|
name: zod.string(),
|
||||||
|
describe: zod.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { assetsId, projectId, type, name, describe } = req.body;
|
||||||
|
|
||||||
|
//获取风格
|
||||||
|
const project = await u.db("t_project").where("id", projectId).select("artStyle", "type", "intro").first();
|
||||||
|
if (!project) return res.status(500).send(success({ message: "项目为空" }));
|
||||||
|
|
||||||
|
const allOutlineDataList: { data: string }[] = await u.db("t_outline").where("projectId", projectId).select("data");
|
||||||
|
|
||||||
|
const itemMap: Record<string, ResultItem> = {};
|
||||||
|
|
||||||
|
if (allOutlineDataList.length > 0)
|
||||||
|
allOutlineDataList.forEach((row) => {
|
||||||
|
const data: OutlineData = JSON.parse(row?.data || "{}");
|
||||||
|
(["characters", "props", "scenes"] as ItemType[]).forEach((type) => {
|
||||||
|
(data[type] || []).forEach((item) => {
|
||||||
|
const key = `${type}-${item.name}`;
|
||||||
|
if (!itemMap[key]) {
|
||||||
|
itemMap[key] = {
|
||||||
|
type,
|
||||||
|
name: item.name,
|
||||||
|
chapterRange: [...(data.chapterRange || [])],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
itemMap[key].chapterRange = Array.from(new Set([...itemMap[key].chapterRange, ...(data.chapterRange || [])]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: ResultItem[] = Object.values(itemMap);
|
||||||
|
|
||||||
|
const promptsList = await u.db("t_prompts").where("code", "in", ["role-polish", "scene-polish", "storyboard-polish", "tool-polish"]);
|
||||||
|
const errPrompts = "不论用户说什么,请直接输出AI配置异常";
|
||||||
|
const getPromptValue = (code: string): string => {
|
||||||
|
const item = promptsList.find((p) => p.code === code);
|
||||||
|
return item?.customValue ?? item?.defaultValue ?? errPrompts;
|
||||||
|
};
|
||||||
|
const role = getPromptValue("role-polish");
|
||||||
|
const scene = getPromptValue("scene-polish");
|
||||||
|
const tool = getPromptValue("tool-polish");
|
||||||
|
const storyboard = getPromptValue("storyboard-polish");
|
||||||
|
|
||||||
|
let systemPrompt = "";
|
||||||
|
let userPrompt = "";
|
||||||
|
if (type == "role") {
|
||||||
|
const data = findItemByName(result, name, "characters");
|
||||||
|
const chapterRange = Array.isArray(data?.chapterRange) ? data.chapterRange : [data?.chapterRange];
|
||||||
|
const novelData = (await u.db("t_novel").whereIn("chapterIndex", chapterRange).select("*")) as NovelChapter[];
|
||||||
|
const results: string = mergeNovelText(novelData);
|
||||||
|
systemPrompt = role;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成角色标准四视图提示词:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 风格: ${project?.artStyle || "未指定"}
|
||||||
|
- 小说原文:${results || "未提供"}
|
||||||
|
- 小说类型: ${project?.type || "未指定"}
|
||||||
|
- 小说背景: ${project?.intro || "未指定"}
|
||||||
|
|
||||||
|
**角色设定:**
|
||||||
|
- 角色名称:${name},
|
||||||
|
- 角色描述:${describe},
|
||||||
|
|
||||||
|
请严格按照系统规范生成人物角色四视图提示词。
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type == "scene") {
|
||||||
|
const data = findItemByName(result, name, "scenes");
|
||||||
|
const chapterRange = Array.isArray(data?.chapterRange) ? data.chapterRange : [data?.chapterRange];
|
||||||
|
const novelData = (await u.db("t_novel").whereIn("chapterIndex", chapterRange).select("*")) as NovelChapter[];
|
||||||
|
const results: string = mergeNovelText(novelData);
|
||||||
|
systemPrompt = scene;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成场景图提示词:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 风格: ${project?.artStyle || "未指定"}
|
||||||
|
- 小说原文:${results || "未提供"}
|
||||||
|
- 小说类型: ${project?.type || "未指定"}
|
||||||
|
- 小说背景: ${project?.intro || "未指定"}
|
||||||
|
|
||||||
|
**场景设定:**
|
||||||
|
- 场景名称:${name},
|
||||||
|
- 场景描述:${describe},
|
||||||
|
|
||||||
|
请严格按照系统规范生成场景图提示词。
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type == "props") {
|
||||||
|
const data = findItemByName(result, name, "props");
|
||||||
|
const chapterRange = Array.isArray(data?.chapterRange) ? data.chapterRange : [data?.chapterRange];
|
||||||
|
const novelData = (await u.db("t_novel").whereIn("chapterIndex", chapterRange).select("*")) as NovelChapter[];
|
||||||
|
const results: string = mergeNovelText(novelData);
|
||||||
|
systemPrompt = tool;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成道具图提示词:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 风格: ${project?.artStyle || "未指定"}
|
||||||
|
- 小说原文:${results || "未提供"}
|
||||||
|
- 小说类型: ${project?.type || "未指定"}
|
||||||
|
- 小说背景: ${project?.intro || "未指定"}
|
||||||
|
|
||||||
|
**道具设定:**
|
||||||
|
- 道具名称:${name},
|
||||||
|
- 道具描述:${describe},
|
||||||
|
|
||||||
|
请严格按照系统规范生成道具图提示词。
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type == "storyboard") {
|
||||||
|
systemPrompt = storyboard;
|
||||||
|
userPrompt = `
|
||||||
|
请根据以下参数生成分镜图提示词:
|
||||||
|
|
||||||
|
**基础参数:**
|
||||||
|
- 风格: ${project?.artStyle || "未指定"}
|
||||||
|
- 小说类型: ${project?.type || "未指定"}
|
||||||
|
- 小说背景: ${project?.intro || "未指定"}
|
||||||
|
|
||||||
|
**分镜设定:**
|
||||||
|
- 分镜名称:${name},
|
||||||
|
- 分镜描述:${describe},
|
||||||
|
|
||||||
|
请严格按照系统规范生成分镜图提示词。
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
async function generatePrompt() {
|
||||||
|
const model = await u.ai.text();
|
||||||
|
const result = await model.invoke({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: systemPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: userPrompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responseFormat: {
|
||||||
|
type: "json_schema",
|
||||||
|
jsonSchema: {
|
||||||
|
name: "json",
|
||||||
|
strict: true,
|
||||||
|
schema: zod.toJSONSchema(jsonSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.json;
|
||||||
|
}
|
||||||
|
const data = (await generatePrompt()) as any;
|
||||||
|
|
||||||
|
if (!data.prompt) return res.status(500).send("失败");
|
||||||
|
|
||||||
|
res.status(200).send(success({ prompt: data.prompt, assetsId }));
|
||||||
|
},
|
||||||
|
);
|
||||||
85
src/routes/assets/saveAssets.ts
Normal file
85
src/routes/assets/saveAssets.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 保存资产图片
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
projectId: z.number(),
|
||||||
|
base64: z.string().optional().nullable(),
|
||||||
|
filePath: z.string().optional().nullable(),
|
||||||
|
prompt: z.string().optional().nullable(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, base64, filePath, prompt, projectId } = req.body;
|
||||||
|
|
||||||
|
let savePath: string | undefined;
|
||||||
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
|
if (base64) {
|
||||||
|
// base64图片上传逻辑
|
||||||
|
const matches = base64.match(/^data:image\/\w+;base64,(.+)$/);
|
||||||
|
const realBase64 = matches ? matches[1] : base64;
|
||||||
|
// 生成新的图片路径
|
||||||
|
savePath = `/${projectId}/assets/${uuidv4()}.png`;
|
||||||
|
// 写入文件
|
||||||
|
await u.oss.writeFile(savePath, Buffer.from(realBase64, "base64"));
|
||||||
|
// 插入图片表
|
||||||
|
await u.db("t_image").insert({
|
||||||
|
assetsId: id,
|
||||||
|
filePath: savePath,
|
||||||
|
type: "image/png",
|
||||||
|
});
|
||||||
|
imageUrl = savePath; // 新图片路径
|
||||||
|
} else if (filePath) {
|
||||||
|
// 前端传入已存在图片路径
|
||||||
|
try {
|
||||||
|
savePath = new URL(filePath).pathname;
|
||||||
|
} catch {
|
||||||
|
savePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查图片表里是否有这条图片
|
||||||
|
const selectedImage = await u.db("t_image").where("filePath", savePath).first();
|
||||||
|
if (!selectedImage) {
|
||||||
|
return res.status(404).send({ success: false, message: "所选图片不存在,请重新生成或选定图片" });
|
||||||
|
}
|
||||||
|
imageUrl = savePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查旧资产图片
|
||||||
|
const oldAsset = await u.db("t_assets").where("id", id).select("filePath", "type").first();
|
||||||
|
|
||||||
|
// 保存新旧图片差异和插临时表逻辑
|
||||||
|
if (imageUrl && ((oldAsset?.filePath && oldAsset.filePath !== imageUrl) || (!oldAsset?.filePath && imageUrl))) {
|
||||||
|
// 新图片保存,移除 t_image 表
|
||||||
|
await u.db("t_image").where("filePath", imageUrl).delete();
|
||||||
|
|
||||||
|
// 原图片如果存在、且不在 t_image 表,插入临时表
|
||||||
|
if (oldAsset?.filePath) {
|
||||||
|
const oldInTemp = await u.db("t_image").where("filePath", oldAsset.filePath).first();
|
||||||
|
if (!oldInTemp) {
|
||||||
|
await u.db("t_image").insert({
|
||||||
|
assetsId: id,
|
||||||
|
filePath: oldAsset.filePath,
|
||||||
|
type: oldAsset.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新资产表图片为新图片
|
||||||
|
await u.db("t_assets").where("id", id).update({ filePath: imageUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新提示信息
|
||||||
|
await u.db("t_assets").where("id", id).update({ prompt });
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "保存资产图片成功" }));
|
||||||
|
},
|
||||||
|
);
|
||||||
39
src/routes/assets/updateAssets.ts
Normal file
39
src/routes/assets/updateAssets.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 更新资产
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
intro: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
videoPrompt: z.string().optional().nullable(),
|
||||||
|
remark: z.string().optional().nullable(),
|
||||||
|
duration: z.number().optional().nullable(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, name, intro, type, prompt, remark, duration, videoPrompt } = req.body;
|
||||||
|
|
||||||
|
await u
|
||||||
|
.db("t_assets")
|
||||||
|
.where("id", id)
|
||||||
|
.update({
|
||||||
|
name,
|
||||||
|
intro,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
remark,
|
||||||
|
videoPrompt,
|
||||||
|
duration: String(duration),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "更新资产成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
27
src/routes/index/index.ts
Normal file
27
src/routes/index/index.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
const router = express.Router();
|
||||||
|
import { z } from "zod";
|
||||||
|
import { error } from "@/lib/responseFormat";
|
||||||
|
|
||||||
|
export default router.get("/", async (req, res, next) => {
|
||||||
|
const id = 14;
|
||||||
|
const targetOutlineData = await u.db("t_outline").where("id", id).select("data").first();
|
||||||
|
if (!targetOutlineData) return res.status(400).send(error("大纲不存在"));
|
||||||
|
//筛选出改大纲特有的资产
|
||||||
|
const allOutlineDataList = await u.db("t_outline").where("projectId", 8).andWhere("id", "!=", id).select("data");
|
||||||
|
//找出目标ID大纲特有的资产名称
|
||||||
|
const allOutlineData = allOutlineDataList
|
||||||
|
.map((item) => {
|
||||||
|
const data = JSON.parse(item?.data || "[]");
|
||||||
|
return [...data.characters, ...data.props, ...data.scenes].map((item: any) => item.name);
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
const targetOutLineNames = JSON.parse(targetOutlineData?.data || "[]");
|
||||||
|
const targetNames = [...targetOutLineNames.characters, ...targetOutLineNames.props, ...targetOutLineNames.scenes].map((item: any) => item.name);
|
||||||
|
|
||||||
|
const diffAssetsNames = targetNames.filter((item) => !allOutlineData.includes(item));
|
||||||
|
|
||||||
|
res.status(200).send(123);
|
||||||
|
});
|
||||||
38
src/routes/novel/addNovel.ts
Normal file
38
src/routes/novel/addNovel.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 新增原文数据
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
data: z.array(
|
||||||
|
z.object({
|
||||||
|
index: z.number(),
|
||||||
|
reel: z.string(),
|
||||||
|
chapter: z.string(),
|
||||||
|
chapterData: z.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, data } = req.body;
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
await u.db("t_novel").insert({
|
||||||
|
projectId,
|
||||||
|
chapterIndex: item.index,
|
||||||
|
reel: item.reel,
|
||||||
|
chapter: item.chapter,
|
||||||
|
chapterData: item.chapterData,
|
||||||
|
createTime: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "新增原文成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
21
src/routes/novel/delNovel.ts
Normal file
21
src/routes/novel/delNovel.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除原文
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_novel").where("id", id).del();
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "删除原文成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
25
src/routes/novel/getNovel.ts
Normal file
25
src/routes/novel/getNovel.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取原文数据
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
|
||||||
|
const data = await u
|
||||||
|
.db("t_novel")
|
||||||
|
.where("projectId", projectId)
|
||||||
|
.select("id", "chapterIndex as index", "reel", "chapter", "chapterData")
|
||||||
|
.orderBy("chapterIndex", "asc");
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
30
src/routes/novel/updateNovel.ts
Normal file
30
src/routes/novel/updateNovel.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 更新原文数据
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
index: z.union([z.number(), z.string()]),
|
||||||
|
reel: z.string(),
|
||||||
|
chapter: z.string(),
|
||||||
|
chapterData: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, index, reel, chapter, chapterData } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_novel").where("id", id).update({
|
||||||
|
chapterIndex: index,
|
||||||
|
reel,
|
||||||
|
chapter,
|
||||||
|
chapterData,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "更新原文成功" }));
|
||||||
|
},
|
||||||
|
);
|
||||||
12
src/routes/other/clearDatabase.ts
Normal file
12
src/routes/other/clearDatabase.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import initDB from "@/lib/initDB";
|
||||||
|
|
||||||
|
import { db } from "@/utils/db";
|
||||||
|
import express from "express";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 清空所有表 (sqlite)
|
||||||
|
export default router.post("/", async (req, res) => {
|
||||||
|
await initDB(db, true);
|
||||||
|
res.status(200).send(success("清空数据库成功"));
|
||||||
|
});
|
||||||
25
src/routes/other/deleteAllData.ts
Normal file
25
src/routes/other/deleteAllData.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除数据库表数据
|
||||||
|
export default router.post("/", async (req, res) => {
|
||||||
|
const projects = await u.db("t_project").select("id");
|
||||||
|
|
||||||
|
const projectIds = projects.map((project) => project.id);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
projectIds.map(async (id) => {
|
||||||
|
try {
|
||||||
|
await u.oss.deleteDirectory(String(id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`删除OSS文件失败,项目ID: ${id}`, error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// await initDB(db, true);
|
||||||
|
|
||||||
|
res.status(200).send(success("清空数据库成功"));
|
||||||
|
});
|
||||||
13
src/routes/other/getCaptcha.ts
Normal file
13
src/routes/other/getCaptcha.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { md5 } from "js-md5";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取验证码
|
||||||
|
export default router.get("/", async (req, res) => {
|
||||||
|
const data: any = { svg: "<svg></svg>", captcha: md5("123") };
|
||||||
|
if (req.app.get("env") === "dev") {
|
||||||
|
data.key = 2;
|
||||||
|
}
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
});
|
||||||
46
src/routes/other/login.ts
Normal file
46
src/routes/other/login.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { success, error } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export function setToken(payload: string | object, expiresIn: string | number, secret: string): string {
|
||||||
|
if (!payload || typeof secret !== "string" || !secret) {
|
||||||
|
throw new Error("参数不合法");
|
||||||
|
}
|
||||||
|
return (jwt.sign as any)(payload, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_user").where("name", "=", username).first();
|
||||||
|
if (!data) return res.status(400).send(error("登录失败"));
|
||||||
|
|
||||||
|
if (data!.password == password && data!.name == username) {
|
||||||
|
const tokenSecret = await u.db("t_setting").where("userId", data.id).select("tokenKey").first();
|
||||||
|
|
||||||
|
const token = setToken(
|
||||||
|
{
|
||||||
|
id: data!.id,
|
||||||
|
name: data!.name,
|
||||||
|
},
|
||||||
|
"180Days",
|
||||||
|
tokenSecret?.tokenKey as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).send(success({ token: "Bearer " + token, name: data!.name, id: data!.id }, "登录成功"));
|
||||||
|
} else {
|
||||||
|
return res.status(400).send(error("用户名或密码错误"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
25
src/routes/outline/addOutline.ts
Normal file
25
src/routes/outline/addOutline.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 新增大纲
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, data } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_outline").insert({
|
||||||
|
data,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "新增大纲成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
155
src/routes/outline/agentsOutline.ts
Normal file
155
src/routes/outline/agentsOutline.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import express from "express";
|
||||||
|
import expressWs, { Application } from "express-ws";
|
||||||
|
import u from "@/utils";
|
||||||
|
import OutlineScript from "@/agents/outlineScript";
|
||||||
|
const router = express.Router();
|
||||||
|
expressWs(router as unknown as Application);
|
||||||
|
|
||||||
|
router.ws("/", async (ws, req) => {
|
||||||
|
let agent: OutlineScript;
|
||||||
|
|
||||||
|
const config = await u.getConfig("language");
|
||||||
|
|
||||||
|
const projectId = req.query.projectId;
|
||||||
|
if (!projectId || typeof projectId !== "string") {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "项目ID缺失" }));
|
||||||
|
ws.close(500, "项目ID缺失");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = new OutlineScript(Number(projectId));
|
||||||
|
|
||||||
|
agent.modelName = config.model ?? "";
|
||||||
|
agent.baseURL = config.baseURL ?? "";
|
||||||
|
agent.apiKey = config.apiKey ?? "";
|
||||||
|
|
||||||
|
// const existing = await u
|
||||||
|
// .db("t_chatHistory")
|
||||||
|
// .where({ projectId: Number(projectId) })
|
||||||
|
// .first();
|
||||||
|
// if (existing) {
|
||||||
|
// try {
|
||||||
|
// const historyData = JSON.parse(existing.data!);
|
||||||
|
// agent.history = [];
|
||||||
|
// agent.novelChapters = existing.novel ? JSON.parse(existing.novel) : [];
|
||||||
|
// } catch (error) {
|
||||||
|
// ws.send(JSON.stringify({ type: "error", data: "历史记录解析异常,将清空历史记录" }));
|
||||||
|
// agent.history = [];
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// 监听各类事件
|
||||||
|
// 流式传输:每个token
|
||||||
|
agent.emitter.on("data", (text) => {
|
||||||
|
ws.send(JSON.stringify({ type: "stream", data: text }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 完整响应结束
|
||||||
|
agent.emitter.on("response", async (text) => {
|
||||||
|
ws.send(JSON.stringify({ type: "response_end", data: text }));
|
||||||
|
await saveHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sub-Agent 流式数据
|
||||||
|
agent.emitter.on("subAgentStream", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "subAgentStream", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sub-Agent 结束
|
||||||
|
agent.emitter.on("subAgentEnd", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "subAgentEnd", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool 调用
|
||||||
|
agent.emitter.on("toolCall", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "toolCall", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.emitter.on("transfer", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "transfer", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.emitter.on("refresh", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "refresh", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.emitter.on("error", (err) => {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: err.toString() }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送初始化完成消息,通知前端可以开始发送消息
|
||||||
|
ws.send(JSON.stringify({ type: "init", data: { projectId } }));
|
||||||
|
|
||||||
|
type DataTyype = "msg" | "setNovel" | "cleanHistory";
|
||||||
|
ws.on("message", async function (rawData: string) {
|
||||||
|
let data: { type: DataTyype; data: any } | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "数据解析异常" }));
|
||||||
|
ws.close(500, "数据解析异常");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "数据格式错误" }));
|
||||||
|
ws.close(500, "数据格式错误");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const novelData = await u
|
||||||
|
.db("t_novel")
|
||||||
|
.where({ projectId: Number(projectId) })
|
||||||
|
.orderBy("chapterIndex", "asc");
|
||||||
|
agent.setNovel(novelData);
|
||||||
|
const msg = data.data;
|
||||||
|
try {
|
||||||
|
switch (data?.type) {
|
||||||
|
case "msg":
|
||||||
|
let prompt = msg.data;
|
||||||
|
if (msg.type == "user") await agent.call(prompt);
|
||||||
|
break;
|
||||||
|
case "cleanHistory":
|
||||||
|
agent.history = [];
|
||||||
|
await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId) })
|
||||||
|
.del();
|
||||||
|
ws.send(JSON.stringify({ type: "notice", data: "历史记录已清空" }));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "数据解析/脚本生成异常" }));
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", async () => {
|
||||||
|
agent?.emitter?.removeAllListeners();
|
||||||
|
await saveHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveHistory() {
|
||||||
|
const history = agent?.history || [];
|
||||||
|
//保存对话记录
|
||||||
|
const existing = await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId), type: "outlineAgent" })
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId), type: "outlineAgent" })
|
||||||
|
.update({ data: JSON.stringify(history), novel: agent?.novelChapters ? JSON.stringify(agent.novelChapters) : "" });
|
||||||
|
} else {
|
||||||
|
await u.db("t_chatHistory").insert({
|
||||||
|
projectId: Number(projectId),
|
||||||
|
data: JSON.stringify(history),
|
||||||
|
novel: agent?.novelChapters ? JSON.stringify(agent.novelChapters) : "",
|
||||||
|
type: "outlineAgent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
22
src/routes/outline/delOutline.ts
Normal file
22
src/routes/outline/delOutline.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { error, success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除大纲
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, projectId } = req.body;
|
||||||
|
|
||||||
|
await u.deleteOutline(id, projectId);
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "删除大纲成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
31
src/routes/outline/getHistory.ts
Normal file
31
src/routes/outline/getHistory.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { error, success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除大纲
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
|
||||||
|
const history = await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId), type: "outlineWebChat" })
|
||||||
|
.first();
|
||||||
|
if (!history) {
|
||||||
|
await u.db("t_chatHistory").insert({
|
||||||
|
projectId: Number(projectId),
|
||||||
|
type: "outlineWebChat",
|
||||||
|
data: "[]",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(success({ data: JSON.parse(history?.data || "[]") }));
|
||||||
|
},
|
||||||
|
);
|
||||||
21
src/routes/outline/getOutline.ts
Normal file
21
src/routes/outline/getOutline.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取大纲数据
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_outline").where("projectId", projectId).select("*");
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
21
src/routes/outline/getPartScript.ts
Normal file
21
src/routes/outline/getPartScript.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取前要数据
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_script").where("projectId", projectId).select("*");
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
19
src/routes/outline/getStoryline.ts
Normal file
19
src/routes/outline/getStoryline.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取故事线数据
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
const data = await u.db("t_storyline").where("projectId", projectId).first();
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
39
src/routes/outline/setHistory.ts
Normal file
39
src/routes/outline/setHistory.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { error, success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除大纲
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, data } = req.body;
|
||||||
|
|
||||||
|
const history = await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId), type: "outlineWebChat" })
|
||||||
|
.first();
|
||||||
|
if (!history) {
|
||||||
|
await u.db("t_chatHistory").insert({
|
||||||
|
projectId: Number(projectId),
|
||||||
|
type: "outlineWebChat",
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId), type: "outlineWebChat" })
|
||||||
|
.update({
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(success("保存成功"));
|
||||||
|
},
|
||||||
|
);
|
||||||
24
src/routes/outline/updateOutline.ts
Normal file
24
src/routes/outline/updateOutline.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 更新大纲
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, data } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_outline").where("id", id).update({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "更新大纲成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
24
src/routes/outline/updateScript.ts
Normal file
24
src/routes/outline/updateScript.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 更新前要
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, content } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_script").where("id", id).update({
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "更新前要成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
27
src/routes/outline/updateStoryline.ts
Normal file
27
src/routes/outline/updateStoryline.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 更新故事线
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, content } = req.body;
|
||||||
|
|
||||||
|
const existing = await u.db("t_storyline").where({ projectId }).first();
|
||||||
|
if (existing) {
|
||||||
|
await u.db("t_storyline").where({ projectId }).update({ content });
|
||||||
|
} else {
|
||||||
|
await u.db("t_storyline").insert({ projectId: projectId, content: content });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "更新故事线成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
33
src/routes/project/addProject.ts
Normal file
33
src/routes/project/addProject.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 新增项目
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
name: z.string(),
|
||||||
|
intro: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
artStyle: z.string(),
|
||||||
|
videoRatio: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { name, intro, type, artStyle, videoRatio } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_project").insert({
|
||||||
|
name,
|
||||||
|
intro,
|
||||||
|
type,
|
||||||
|
artStyle,
|
||||||
|
videoRatio,
|
||||||
|
userId: 1,
|
||||||
|
createTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "新增项目成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
59
src/routes/project/delProject.ts
Normal file
59
src/routes/project/delProject.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除项目
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id } = req.body;
|
||||||
|
|
||||||
|
const scriptData = await u.db("t_script").where("projectId", id).select("id");
|
||||||
|
const scriptIds = scriptData.map((item: any) => item.id);
|
||||||
|
|
||||||
|
const assetsData = await u.db("t_assets").where("projectId", id).select("id");
|
||||||
|
const assetsIds = assetsData.map((item: any) => item.id);
|
||||||
|
|
||||||
|
const videoData = await u.db("t_video").whereIn("scriptId", scriptIds).select("id");
|
||||||
|
const videoIds = videoData.map((item: any) => item.id);
|
||||||
|
|
||||||
|
await u.db("t_project").where("id", id).delete();
|
||||||
|
await u.db("t_novel").where("projectId", id).delete();
|
||||||
|
await u.db("t_storyline").where("projectId", id).delete();
|
||||||
|
await u.db("t_outline").where("projectId", id).delete();
|
||||||
|
|
||||||
|
await u.db("t_script").where("projectId", id).delete();
|
||||||
|
await u.db("t_assets").where("projectId", id).delete();
|
||||||
|
|
||||||
|
const tempAssetsQuery = u.db("t_image").where("projectId", id);
|
||||||
|
if (assetsIds.length > 0) {
|
||||||
|
tempAssetsQuery.orWhereIn("assetsId", assetsIds);
|
||||||
|
}
|
||||||
|
if (scriptIds.length > 0) {
|
||||||
|
tempAssetsQuery.orWhereIn("scriptId", scriptIds);
|
||||||
|
}
|
||||||
|
if (videoIds.length > 0) {
|
||||||
|
tempAssetsQuery.orWhereIn("videoId", videoIds);
|
||||||
|
}
|
||||||
|
await tempAssetsQuery.delete();
|
||||||
|
|
||||||
|
await u.db("t_video").whereIn("scriptId", scriptIds).delete();
|
||||||
|
|
||||||
|
await u.db("t_chatHistory").where("projectId", id).delete();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await u.oss.deleteDirectory(`${id}/`);
|
||||||
|
console.log(`项目 ${id} 的OSS文件夹删除成功`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`项目 ${id} 没有对应的OSS文件夹,跳过删除`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "删除项目成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
12
src/routes/project/getProject.ts
Normal file
12
src/routes/project/getProject.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取项目
|
||||||
|
export default router.post("/", async (req, res) => {
|
||||||
|
const data = await u.db("t_project").select("*");
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
});
|
||||||
34
src/routes/project/getProjectCount.ts
Normal file
34
src/routes/project/getProjectCount.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取项目统计
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
|
||||||
|
const scripts = await u.db("t_script").where("projectId", projectId).select("id");
|
||||||
|
const scriptIds = scripts.map((item: any) => item.id);
|
||||||
|
|
||||||
|
const roleCount: any = await u.db("t_assets").where("projectId", projectId).where("type", "角色").count("* as total").first();
|
||||||
|
const scriptCount: any = await u.db("t_script").where("projectId", projectId).count("* as total").first();
|
||||||
|
const videoCount: any = await u.db("t_video").whereIn("scriptId", scriptIds).count("* as total").first();
|
||||||
|
const storyboardCount: any = await u.db("t_assets").whereIn("scriptId", scriptIds).where("type", "分镜").count("* as total").first();
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
roleCount: roleCount?.total || 0,
|
||||||
|
scriptCount: scriptCount?.total || 0,
|
||||||
|
videoCount: videoCount?.total || 0,
|
||||||
|
storyboardCount: storyboardCount?.total || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
21
src/routes/project/getSingleProject.ts
Normal file
21
src/routes/project/getSingleProject.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取单个项目
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_project").where("id", id).select("*");
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
30
src/routes/project/updateProject.ts
Normal file
30
src/routes/project/updateProject.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 修改项目
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
intro: z.string().optional().nullable(),
|
||||||
|
type: z.string().optional().nullable(),
|
||||||
|
artStyle: z.string().optional().nullable(),
|
||||||
|
videoRatio: z.string().optional().nullable(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, intro, type, artStyle, videoRatio } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_project").where("id", id).update({
|
||||||
|
intro,
|
||||||
|
type,
|
||||||
|
artStyle,
|
||||||
|
videoRatio,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "修改成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
13
src/routes/prompt/getPrompts.ts
Normal file
13
src/routes/prompt/getPrompts.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取提示词
|
||||||
|
export default router.get("/", async (req, res) => {
|
||||||
|
const data = await u.db("t_prompts");
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
});
|
||||||
28
src/routes/prompt/updatePrompt.ts
Normal file
28
src/routes/prompt/updatePrompt.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 更新提示词
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
customValue: z.string(),
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, customValue, code } = req.body;
|
||||||
|
|
||||||
|
await u
|
||||||
|
.db("t_prompts")
|
||||||
|
.update({
|
||||||
|
customValue: customValue,
|
||||||
|
})
|
||||||
|
.where("id", id);
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "更新提示词成功" }));
|
||||||
|
},
|
||||||
|
);
|
||||||
72
src/routes/script/geScriptApi.ts
Normal file
72
src/routes/script/geScriptApi.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
interface Asset {
|
||||||
|
id: number;
|
||||||
|
type: string; // "角色" 或其他
|
||||||
|
name: string;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScriptRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
outlineId: number;
|
||||||
|
projectId: number;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId } = req.body;
|
||||||
|
|
||||||
|
//查询剧本和大纲数据
|
||||||
|
const rows: ScriptRow[] = await u
|
||||||
|
.db("t_outline")
|
||||||
|
.leftJoin("t_script", "t_outline.id", "t_script.outlineId")
|
||||||
|
.where("t_outline.projectId", projectId)
|
||||||
|
.select("t_script.id", "t_script.name", "t_script.content", "t_script.outlineId", "t_script.projectId", "t_outline.data");
|
||||||
|
|
||||||
|
// 查询所有的资产
|
||||||
|
const assets: Asset[] = await u
|
||||||
|
.db("t_assets")
|
||||||
|
.where("projectId", projectId)
|
||||||
|
.andWhere("type", "<>", "分镜")
|
||||||
|
.select("id", "type", "name", "filePath", "intro", "prompt");
|
||||||
|
|
||||||
|
const data = rows.map((item) => {
|
||||||
|
const parseData = JSON.parse(item.data);
|
||||||
|
const charData = parseData.characters.map((i: Asset) => i.name);
|
||||||
|
const propsData = parseData.props.map((i: Asset) => i.name);
|
||||||
|
const sceneData = parseData.scenes.map((i: Asset) => i.name);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
element: [
|
||||||
|
...assets.filter((i) => i.type == "道具" && propsData.includes(i.name)),
|
||||||
|
...assets.filter((i) => i.type == "角色" && charData.includes(i.name)),
|
||||||
|
...assets.filter((i) => i.type == "场景" && sceneData.includes(i.name)),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
data.map(async (script) => {
|
||||||
|
await Promise.all(
|
||||||
|
script.element.map(async (el) => {
|
||||||
|
el.filePath = el.filePath ? await u.oss.getFileUrl(el.filePath) : "";
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
56
src/routes/script/generateScriptApi.ts
Normal file
56
src/routes/script/generateScriptApi.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { generateScript } from "@/utils/generateScript";
|
||||||
|
const router = express.Router();
|
||||||
|
interface NovelChapter {
|
||||||
|
id: number;
|
||||||
|
reel: string;
|
||||||
|
chapter: string;
|
||||||
|
chapterData: string;
|
||||||
|
projectId: number;
|
||||||
|
}
|
||||||
|
function mergeNovelText(novelData: NovelChapter[]): string {
|
||||||
|
if (!Array.isArray(novelData)) return "";
|
||||||
|
return novelData
|
||||||
|
.map((chap) => {
|
||||||
|
return `${chap.chapter.trim()}\n\n${chap.chapterData.trim().replace(/\r?\n/g, "\n")}\n`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成剧本
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
outlineId: z.number(),
|
||||||
|
scriptId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { outlineId, scriptId } = req.body;
|
||||||
|
const outlineData = await u.db("t_outline").where("id", outlineId).select("*").first();
|
||||||
|
if (!outlineData) return res.status(500).send(success({ message: "大纲为空" }));
|
||||||
|
const parameter = JSON.parse(outlineData.data!);
|
||||||
|
|
||||||
|
const novelData = (await u
|
||||||
|
.db("t_novel")
|
||||||
|
.whereIn("chapterIndex", parameter.chapterRange)
|
||||||
|
.where("projectId", outlineData.projectId)
|
||||||
|
.select("*")) as NovelChapter[];
|
||||||
|
|
||||||
|
if (novelData.length == 0) return res.status(500).send(success({ message: "原文为空" }));
|
||||||
|
|
||||||
|
const result: string = mergeNovelText(novelData);
|
||||||
|
|
||||||
|
const data = await generateScript(parameter ?? "", result ?? "");
|
||||||
|
if (!data) return res.status(500).send({ message: "生成剧本失败" });
|
||||||
|
|
||||||
|
await u.db("t_script").where("id", scriptId).update({
|
||||||
|
content: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "生成剧本成功" }));
|
||||||
|
},
|
||||||
|
);
|
||||||
26
src/routes/script/generateScriptSave.ts
Normal file
26
src/routes/script/generateScriptSave.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { generateScript } from "@/utils/generateScript";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 生成剧本
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
outlineId: z.number(),
|
||||||
|
scriptId: z.number(),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { outlineId, scriptId, content } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_script").where("id", scriptId).update({
|
||||||
|
content: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "保存成功" }));
|
||||||
|
},
|
||||||
|
);
|
||||||
41
src/routes/setting/getSetting.ts
Normal file
41
src/routes/setting/getSetting.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
userId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
const settingData = await u.db("t_setting").select("*");
|
||||||
|
|
||||||
|
const configData = await u.db("t_config").where("userId", userId).select("*").orderBy("index", "asc");
|
||||||
|
|
||||||
|
const parsedData = settingData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
imageModel: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(item.imageModel ?? "{}");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
languageModel: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(item.languageModel ?? "{}");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
videoModel: configData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).send(success(parsedData));
|
||||||
|
}
|
||||||
|
);
|
||||||
55
src/routes/setting/updateSetting.ts
Normal file
55
src/routes/setting/updateSetting.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 修改全局配置
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
userId: z.number(),
|
||||||
|
imageModel: z.object().optional(),
|
||||||
|
videoModel: z.array(z.object()).optional(),
|
||||||
|
languageModel: z.object().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { userId, imageModel, videoModel, languageModel, name, password } = req.body;
|
||||||
|
|
||||||
|
await u
|
||||||
|
.db("t_setting")
|
||||||
|
.where("userId", userId)
|
||||||
|
.update({
|
||||||
|
imageModel: JSON.stringify(imageModel),
|
||||||
|
languageModel: JSON.stringify(languageModel),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoModel) {
|
||||||
|
await u.db("t_config").where("type", "video").delete();
|
||||||
|
|
||||||
|
for (const item of videoModel) {
|
||||||
|
await u.db("t_config").insert({
|
||||||
|
type: "video",
|
||||||
|
name: item.name,
|
||||||
|
model: item.model,
|
||||||
|
apiKey: item.apiKey,
|
||||||
|
baseUrl: item.baseUrl,
|
||||||
|
index: item.index,
|
||||||
|
createTime: Date.now(),
|
||||||
|
userId,
|
||||||
|
manufacturer: item.manufacturer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await u.db("t_user").where("id", userId).update({
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "修改全局配置成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
100
src/routes/storyboard/batchSuperScoreImage.ts
Normal file
100
src/routes/storyboard/batchSuperScoreImage.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { error, success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// url转base64
|
||||||
|
async function urlToBase64(imageUrl: string): Promise<string> {
|
||||||
|
const response = await axios.get(imageUrl, { responseType: "arraybuffer" });
|
||||||
|
const contentType = response.headers["content-type"] || "image/png";
|
||||||
|
const base64 = Buffer.from(response.data, "binary").toString("base64");
|
||||||
|
return `data:${contentType};base64,${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超分并保存到 oss
|
||||||
|
async function superResolutionAndSave(
|
||||||
|
src: string,
|
||||||
|
projectId: number,
|
||||||
|
videoRatio: string,
|
||||||
|
): Promise<{ ossPath: string; base64: string }> {
|
||||||
|
const contentStr = await u.ai.generateImage({
|
||||||
|
aspectRatio: videoRatio,
|
||||||
|
size: "1K",
|
||||||
|
resType: "b64",
|
||||||
|
systemPrompt: "你的核心任务是将所给的图片超分到 1K ,不改变图片任何内容,仅改变分辨率",
|
||||||
|
prompt: "你的核心任务是将所给的图片超分到 1K ,不改变图片任何内容,仅改变分辨率",
|
||||||
|
imageBase64: [await urlToBase64(src)],
|
||||||
|
});
|
||||||
|
const match = contentStr.match(/base64,([A-Za-z0-9+/=]+)/);
|
||||||
|
const base64Str = match ? match[1] : contentStr;
|
||||||
|
const buffer = Buffer.from(base64Str, "base64");
|
||||||
|
const ossPath = `/${projectId}/chat/${v4()}.jpg`;
|
||||||
|
await u.oss.writeFile(ossPath, buffer);
|
||||||
|
return { ossPath, base64: `data:image/jpg;base64,${base64Str}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
scriptId: z.number().nullable(),
|
||||||
|
imageList: z.array(
|
||||||
|
z.object({
|
||||||
|
cells: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
src: z.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, scriptId, imageList } = req.body;
|
||||||
|
const scriptData = await u.db("t_script").where("id", scriptId).select("content").first();
|
||||||
|
if (!scriptData) return res.status(500).send(error("剧本不存在"));
|
||||||
|
const projectData = await u.db("t_project").where({ id: +projectId }).select("artStyle", "videoRatio").first();
|
||||||
|
if (!projectData) return res.status(500).send(error("项目不存在"));
|
||||||
|
|
||||||
|
// 遍历处理每个分镜段
|
||||||
|
const processSegment = async (
|
||||||
|
segment: { cells: { id: string; src: string }[] }
|
||||||
|
) => {
|
||||||
|
// 超分所有 cell
|
||||||
|
const cellsWithSuperscore = await Promise.all(
|
||||||
|
segment.cells.map(async (cell) => {
|
||||||
|
const { ossPath } = await superResolutionAndSave(cell.src, projectId, projectData.videoRatio!);
|
||||||
|
return {
|
||||||
|
id: cell.id,
|
||||||
|
projectId,
|
||||||
|
scriptId,
|
||||||
|
filePath: ossPath, // oss 路径(未签名)
|
||||||
|
src: cell.src,
|
||||||
|
type: "分镜"
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return cellsWithSuperscore;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理每个段
|
||||||
|
const results = await Promise.allSettled(imageList.map(processSegment));
|
||||||
|
|
||||||
|
// 展开放回并签名 filePath
|
||||||
|
const flatList = await Promise.all(
|
||||||
|
results.flatMap((item: any) =>
|
||||||
|
(item.value as any[]).map(async (cell) => ({
|
||||||
|
...cell,
|
||||||
|
filePath: await u.oss.getFileUrl(cell.filePath ?? ""),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
res.status(200).send(success(flatList));
|
||||||
|
}
|
||||||
|
);
|
||||||
192
src/routes/storyboard/chatStoryboard.ts
Normal file
192
src/routes/storyboard/chatStoryboard.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import express from "express";
|
||||||
|
import expressWs, { Application } from "express-ws";
|
||||||
|
import u from "@/utils";
|
||||||
|
import Storyboard from "@/agents/storyboard";
|
||||||
|
const router = express.Router();
|
||||||
|
expressWs(router as unknown as Application);
|
||||||
|
|
||||||
|
router.ws("/", async (ws, req) => {
|
||||||
|
let agent: Storyboard;
|
||||||
|
|
||||||
|
const config = await u.getConfig("language");
|
||||||
|
|
||||||
|
const projectId = req.query.projectId;
|
||||||
|
const scriptId = req.query.scriptId;
|
||||||
|
if (!projectId || typeof projectId !== "string" || !scriptId || typeof scriptId !== "string") {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "项目ID或脚本ID缺失" }));
|
||||||
|
ws.close(500, "项目ID或脚本ID缺失");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = new Storyboard(Number(projectId), Number(scriptId));
|
||||||
|
|
||||||
|
agent.modelName = config.model ?? "";
|
||||||
|
agent.baseURL = config.baseURL ?? "";
|
||||||
|
agent.apiKey = config.apiKey ?? "";
|
||||||
|
|
||||||
|
const existing = await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId) })
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
agent.history = JSON.parse(existing.data!);
|
||||||
|
agent.novelChapters = existing.novel ? JSON.parse(existing.novel) : [];
|
||||||
|
} catch (error) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "历史记录解析异常,将清空历史记录" }));
|
||||||
|
agent.history = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agent.history = [];
|
||||||
|
// 监听各类事件
|
||||||
|
// 流式传输:每个token
|
||||||
|
agent.emitter.on("data", (text) => {
|
||||||
|
ws.send(JSON.stringify({ type: "stream", data: text }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 完整响应结束
|
||||||
|
agent.emitter.on("response", async (text) => {
|
||||||
|
ws.send(JSON.stringify({ type: "response_end", data: text }));
|
||||||
|
await saveHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sub-Agent 流式数据
|
||||||
|
agent.emitter.on("subAgentStream", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "subAgentStream", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sub-Agent 结束
|
||||||
|
agent.emitter.on("subAgentEnd", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "subAgentEnd", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool 调用
|
||||||
|
agent.emitter.on("toolCall", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "toolCall", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.emitter.on("transfer", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "transfer", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.emitter.on("refresh", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "refresh", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.emitter.on("error", (err) => {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: err.toString() }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 片段数据更新
|
||||||
|
agent.emitter.on("segmentsUpdated", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "segmentsUpdated", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分镜数据更新
|
||||||
|
agent.emitter.on("shotsUpdated", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "shotsUpdated", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分镜图生成开始
|
||||||
|
agent.emitter.on("shotImageGenerateStart", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "shotImageGenerateStart", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分镜图生成进度
|
||||||
|
agent.emitter.on("shotImageGenerateProgress", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "shotImageGenerateProgress", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分镜图生成完成
|
||||||
|
agent.emitter.on("shotImageGenerateComplete", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "shotImageGenerateComplete", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分镜图生成错误
|
||||||
|
agent.emitter.on("shotImageGenerateError", (data) => {
|
||||||
|
ws.send(JSON.stringify({ type: "shotImageGenerateError", data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送初始化完成消息,通知前端可以开始发送消息
|
||||||
|
ws.send(JSON.stringify({ type: "init", data: { projectId, scriptId } }));
|
||||||
|
|
||||||
|
type DataTyype = "msg" | "cleanHistory" | "generateShotImage" | "replaceShot";
|
||||||
|
ws.on("message", async function (rawData: string) {
|
||||||
|
let data: { type: DataTyype; data: any } | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "数据解析异常" }));
|
||||||
|
ws.close(500, "数据解析异常");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "数据格式错误" }));
|
||||||
|
ws.close(500, "数据格式错误");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = data.data;
|
||||||
|
try {
|
||||||
|
switch (data?.type) {
|
||||||
|
case "msg":
|
||||||
|
let prompt = msg.data;
|
||||||
|
if (msg.type == "user") await agent.call(prompt);
|
||||||
|
break;
|
||||||
|
case "cleanHistory":
|
||||||
|
agent.history = [];
|
||||||
|
await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId) })
|
||||||
|
.del();
|
||||||
|
ws.send(JSON.stringify({ type: "notice", data: "历史记录已清空" }));
|
||||||
|
break;
|
||||||
|
case "generateShotImage":
|
||||||
|
agent.history = [];
|
||||||
|
await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId) })
|
||||||
|
.del();
|
||||||
|
ws.send(JSON.stringify({ type: "notice", data: "历史记录已清空" }));
|
||||||
|
break;
|
||||||
|
case "replaceShot":
|
||||||
|
agent.updatePreShots(msg.segmentId, msg.cellId, msg.cell);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: "数据解析/脚本生成异常" }));
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", async () => {
|
||||||
|
agent?.emitter?.removeAllListeners();
|
||||||
|
await saveHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveHistory() {
|
||||||
|
const history = agent?.history || [];
|
||||||
|
//保存对话记录
|
||||||
|
const existing = await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId), type: "storyboardAgent" })
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
await u
|
||||||
|
.db("t_chatHistory")
|
||||||
|
.where({ projectId: Number(projectId), type: "storyboardAgent" })
|
||||||
|
.update({ data: JSON.stringify(history), novel: agent?.novelChapters ? JSON.stringify(agent.novelChapters) : "" });
|
||||||
|
} else {
|
||||||
|
await u.db("t_chatHistory").insert({
|
||||||
|
projectId: Number(projectId),
|
||||||
|
data: JSON.stringify(history),
|
||||||
|
novel: agent?.novelChapters ? JSON.stringify(agent.novelChapters) : "",
|
||||||
|
type: "storyboardAgent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
39
src/routes/storyboard/generateShotImage.ts
Normal file
39
src/routes/storyboard/generateShotImage.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import generateImageTool from "@/agents/storyboard/generateImageTool";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
import fs from "fs";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 生成分镜图
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
segmentId: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number().nullable(),
|
||||||
|
cells: z.array(z.object({ src: z.string().optional(), prompt: z.string() })),
|
||||||
|
scriptId: z.number(),
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { cells, scriptId, projectId } = req.body;
|
||||||
|
|
||||||
|
const buffer = await generateImageTool(cells, scriptId, projectId);
|
||||||
|
|
||||||
|
fs.writeFileSync("merged.jpg", buffer);
|
||||||
|
|
||||||
|
return res.json(success(buffer));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("生成片段图失败:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "生成片段图失败",
|
||||||
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
39
src/routes/storyboard/generateStoryboardApi.ts
Normal file
39
src/routes/storyboard/generateStoryboardApi.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 生成分镜图
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
filePath: z.object(),
|
||||||
|
prompt: z.string(),
|
||||||
|
projectId: z.number(),
|
||||||
|
assetsId: z.any(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { filePath, prompt, projectId, assetsId } = req.body;
|
||||||
|
|
||||||
|
let data = await u.editImage(filePath, prompt, projectId);
|
||||||
|
const returnData: {
|
||||||
|
id: number | null;
|
||||||
|
url: string | null;
|
||||||
|
} = {
|
||||||
|
id: null,
|
||||||
|
url: null,
|
||||||
|
};
|
||||||
|
if (assetsId) {
|
||||||
|
const [id] = await u.db("t_image").insert({
|
||||||
|
filePath: data,
|
||||||
|
assetsId: assetsId,
|
||||||
|
});
|
||||||
|
returnData.id = id!;
|
||||||
|
}
|
||||||
|
returnData.url = await u.oss.getFileUrl(data);
|
||||||
|
|
||||||
|
res.status(200).send(success(returnData));
|
||||||
|
}
|
||||||
|
);
|
||||||
230
src/routes/storyboard/generateVideoPrompt.ts
Normal file
230
src/routes/storyboard/generateVideoPrompt.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { error, success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const cellsResultSchema = z.object({
|
||||||
|
time: z.number().describe("时长,镜头时长 1-15"),
|
||||||
|
content: z.string().describe("提示词内容"),
|
||||||
|
name: z.string().describe("分镜名称"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
你是一名资深动画导演,擅长将静态分镜转化为简洁、专业、详尽的 Motion Prompt(视频生成动作提示)。你理解镜头语言、情绪节奏,能补充丰富但不重复静态元素,只突出变化与动态。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
你将接收用户输入的:
|
||||||
|
- **分镜图片**(单张)
|
||||||
|
- **分镜提示词**(对应该镜头)
|
||||||
|
- **剧本内容**
|
||||||
|
|
||||||
|
你需输出**规范的 Motion Prompt JSON 对象**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心要求
|
||||||
|
|
||||||
|
### 1. 画面类型描述(必需,开头一句)
|
||||||
|
- 明确本分镜属于:**前景/近景/中景/远景/全景**
|
||||||
|
- 表述格式:"中景。" / "近景。" / "远景。" / "全景。"
|
||||||
|
|
||||||
|
### 3. 细致动作叙述
|
||||||
|
清晰分别描述以下要素:
|
||||||
|
- **镜头运动**(1种,5-20字):推拉摇移、跟随、固定等
|
||||||
|
- **角色核心动作**(1-2种,20-60字):主体动作+情绪细节
|
||||||
|
- **环境动态**(0-1种,10-30字):光影、物体、自然元素变化
|
||||||
|
- **速度节奏**(5-15字):缓慢、急促、平稳等
|
||||||
|
- **氛围风格**(可选,10-20字):情绪渲染、视觉基调
|
||||||
|
|
||||||
|
用"," "并且" "同时"等词串联,使句子流畅连贯。
|
||||||
|
|
||||||
|
### 4. 长度优化
|
||||||
|
- **content 必须在 80-150 字之间**
|
||||||
|
- 若不足 80 字,补充:
|
||||||
|
- 角色细微神态(眼神、呼吸、肌肉紧张度)
|
||||||
|
- 动作过渡细节(转身、停顿、重心转移)
|
||||||
|
- 环境反应(光影变化、物体晃动)
|
||||||
|
- **禁止引入图片中已有的静态描述**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结构推荐
|
||||||
|
|
||||||
|
**标准结构:**
|
||||||
|
画面类型。镜头运动,角色主动作+情绪表现+微动作细节,环境动态(如有),速度节奏,氛围渲染。
|
||||||
|
|
||||||
|
**参考示例:**
|
||||||
|
- 中景。镜头缓慢推进,角色身体微微紧绷,神情凝重,缓缓转头注视门口,眉头微皱、唇角轻颤,光影在脸上拉出一缕阴影,衣角随动作轻晃,气氛变得紧张。
|
||||||
|
- 远景。镜头稳定,角色站立不动,但指尖不停地敲打桌面,目光游移不定,窗外树影摇曳,光线逐渐变暗,整体节奏平稳,渲染出迟疑与不安。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 禁忌
|
||||||
|
|
||||||
|
❌ 不重复任何静态画面元素(外观、场景、服装、道具等)
|
||||||
|
❌ 不使用否定句、抽象形容词
|
||||||
|
❌ 不超过 2 种主体动作、1 种镜头运动、1 种环境动态
|
||||||
|
❌ 不分多场景,单个 content 不超过 200 字
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
返回 **JSON 对象**,包含:
|
||||||
|
|
||||||
|
{
|
||||||
|
"time": 数字(1-15,镜头时长秒数),
|
||||||
|
"name": "字符串(2-6字,概括镜头动态/情绪)",
|
||||||
|
"content": "字符串(80-150字,首句为画面类型,充分描述动态细节)"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
- **time**:根据动作复杂度合理分配,简单动作 2-5 秒,复杂动作 6-10 秒
|
||||||
|
- **name**:精炼概括本镜头核心动态或情绪转折
|
||||||
|
- **content**:首句必须是画面类型,后续流畅衔接动态描述
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 处理流程
|
||||||
|
|
||||||
|
1. **分析输入的单张图片**
|
||||||
|
2. **生成对应的 JSON 对象**
|
||||||
|
3. **检查 content 字段:**
|
||||||
|
- 首句是否为画面类型
|
||||||
|
- 字数是否在 80-150 之间
|
||||||
|
- 是否避免了静态描述
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
现在请根据我提供的分镜内容,严格按照以上规则输出 Motion Prompt JSON 对象。
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 生成单个分镜提示
|
||||||
|
async function generateSingleVideoPrompt({
|
||||||
|
scriptText,
|
||||||
|
storyboardPrompt,
|
||||||
|
ossPath,
|
||||||
|
}: {
|
||||||
|
scriptText: string;
|
||||||
|
storyboardPrompt: string;
|
||||||
|
ossPath: string;
|
||||||
|
}): Promise<{ content: string; time: number; name: string }> {
|
||||||
|
let rootDir: string;
|
||||||
|
if (typeof process.versions?.electron !== "undefined") {
|
||||||
|
const { app } = require("electron");
|
||||||
|
const userDataDir: string = app.getPath("userData");
|
||||||
|
rootDir = path.join(userDataDir, "uploads");
|
||||||
|
} else {
|
||||||
|
rootDir = path.join(process.cwd(), "uploads");
|
||||||
|
}
|
||||||
|
|
||||||
|
let imagePath = ossPath;
|
||||||
|
if (ossPath.includes("http")) {
|
||||||
|
imagePath = new URL(ossPath).pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await u.ai.text({});
|
||||||
|
if (!model) {
|
||||||
|
throw new Error("无法获取语言模型,请检查语言模型配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: any[] = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `剧本内容:${scriptText}\n分镜提示词:${storyboardPrompt}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "local",
|
||||||
|
path: path.join(rootDir, imagePath),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await model.invoke({
|
||||||
|
messages,
|
||||||
|
responseFormat: {
|
||||||
|
type: "json_schema",
|
||||||
|
jsonSchema: {
|
||||||
|
name: "json",
|
||||||
|
strict: true,
|
||||||
|
schema: z.toJSONSchema(cellsResultSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || !result.json) {
|
||||||
|
console.error("AI 返回结果为空:", result);
|
||||||
|
throw new Error("AI 返回结果为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = result.json as { content: string; time: number; name: string };
|
||||||
|
if (!json.content || json.time === undefined || !json.name) {
|
||||||
|
console.error("AI 返回格式错误:", result.json);
|
||||||
|
throw new Error("AI 返回格式错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("generateSingleVideoPrompt 调用失败:", err?.message || err);
|
||||||
|
throw new Error(`生成视频提示词失败: ${err?.message || "未知错误"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 主路由 - 单张图片处理
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
scriptId: z.number().nullable(),
|
||||||
|
id: z.string(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
src: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, scriptId, id, prompt: imagePrompt, src } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scriptData = await u.db("t_script").where("id", scriptId).select("content").first();
|
||||||
|
if (!scriptData) return res.status(500).send(error("剧本不存在"));
|
||||||
|
|
||||||
|
const projectData = await u.db("t_project").where({ id: +projectId }).select("artStyle", "videoRatio").first();
|
||||||
|
if (!projectData) return res.status(500).send(error("项目不存在"));
|
||||||
|
|
||||||
|
const result = await generateSingleVideoPrompt({
|
||||||
|
scriptText: scriptData.content!,
|
||||||
|
storyboardPrompt: imagePrompt || "",
|
||||||
|
ossPath: src,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(
|
||||||
|
success({
|
||||||
|
id,
|
||||||
|
videoPrompt: result.content || "",
|
||||||
|
prompt: imagePrompt,
|
||||||
|
duration: String(result.time || ""),
|
||||||
|
projectId,
|
||||||
|
type: "分镜",
|
||||||
|
name: result.name || "",
|
||||||
|
scriptId,
|
||||||
|
src,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("生成视频提示词失败:", err?.message || err);
|
||||||
|
res.status(500).send(error(err?.message || "生成视频提示词失败"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
68
src/routes/storyboard/getStoryboard.ts
Normal file
68
src/routes/storyboard/getStoryboard.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取分镜
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
scriptId: z.number(),
|
||||||
|
projectId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { scriptId } = req.body;
|
||||||
|
|
||||||
|
const assets = await u
|
||||||
|
.db("t_assets")
|
||||||
|
.where("scriptId", scriptId)
|
||||||
|
.where("type", "分镜")
|
||||||
|
.select("id", "name", "intro", "prompt", "filePath", "duration", "videoPrompt", "scriptId", "type", "segmentId", "shotIndex").orderBy("segmentId", "asc").orderBy("shotIndex", "asc");
|
||||||
|
|
||||||
|
const assetsIds = assets.map((item: any) => item.id);
|
||||||
|
|
||||||
|
const generateImg = await u.db("t_image").whereIn("assetsId", assetsIds).where("type", "分镜").select("assetsId", "filePath");
|
||||||
|
|
||||||
|
for (const item of assets) {
|
||||||
|
if (!item.filePath) {
|
||||||
|
item.filePath = "";
|
||||||
|
}
|
||||||
|
item.filePath = await u.oss.getFileUrl(item.filePath ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await Promise.all(
|
||||||
|
assets.map(async (item: any) => {
|
||||||
|
const imgArr = await Promise.all(
|
||||||
|
generateImg
|
||||||
|
.filter((img: any) => Number(img.assetsId) === Number(item.id))
|
||||||
|
.map(async (img: any) => {
|
||||||
|
return {
|
||||||
|
...img,
|
||||||
|
filePath: await u.oss.getFileUrl(img.filePath ?? ""),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
intro: item.intro,
|
||||||
|
prompt: item.prompt,
|
||||||
|
videoPrompt: item.videoPrompt,
|
||||||
|
filePath: item.filePath,
|
||||||
|
type: item.type,
|
||||||
|
scriptId: item.scriptId,
|
||||||
|
duration: item.duration,
|
||||||
|
segmentId: item.segmentId ?? 1,
|
||||||
|
shotIndex: item.shotIndex ?? 1,
|
||||||
|
generateImg: imgArr,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
44
src/routes/storyboard/keepStoryboard.ts
Normal file
44
src/routes/storyboard/keepStoryboard.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 保存分镜图
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
results: z.array(
|
||||||
|
z.object({
|
||||||
|
videoPrompt: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
duration: z.string(),
|
||||||
|
projectId: z.number(),
|
||||||
|
filePath: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
scriptId: z.number(),
|
||||||
|
segmentId: z.number(),
|
||||||
|
shotIndex: z.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { results } = req.body;
|
||||||
|
// const assetsIds = await u.db("t_assets").where("scriptId", results[0].scriptId).andWhere("type", "分镜").select("id").pluck("id");
|
||||||
|
const list = results.map((item: any) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
filePath: new URL(item.filePath).pathname,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// 按 base64Data 原始顺序过滤、插库
|
||||||
|
await u.db("t_assets").insert(list);
|
||||||
|
// // 完成后删除旧分镜资源
|
||||||
|
// if (assetsIds && assetsIds.length > 0) {
|
||||||
|
// await u.db("t_assets").whereIn("id", assetsIds).delete();
|
||||||
|
// }
|
||||||
|
res.status(200).send({ message: "保存分镜图成功" });
|
||||||
|
},
|
||||||
|
);
|
||||||
48
src/routes/storyboard/saveStoryboard.ts
Normal file
48
src/routes/storyboard/saveStoryboard.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 保存分镜图
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
filePath: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { filePath, id, prompt } = req.body;
|
||||||
|
const savePath = new URL(filePath).pathname;
|
||||||
|
|
||||||
|
let imageUrl = "";
|
||||||
|
|
||||||
|
const oldImage = await u.db("t_assets").where("id", id).select("filePath").first();
|
||||||
|
const oldFilePath = oldImage?.filePath;
|
||||||
|
|
||||||
|
if (!oldFilePath || oldFilePath !== savePath) {
|
||||||
|
imageUrl = savePath;
|
||||||
|
|
||||||
|
if (oldFilePath) {
|
||||||
|
await u.db("t_image").insert({
|
||||||
|
assetsId: id,
|
||||||
|
filePath: oldFilePath,
|
||||||
|
type: "分镜",
|
||||||
|
});
|
||||||
|
|
||||||
|
await u.db("t_image").where("assetsId", id).andWhere("filePath", savePath).del();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imageUrl = oldFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
await u.db("t_assets").where("id", id).update({
|
||||||
|
filePath: imageUrl,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({ message: "保存分镜图成功" });
|
||||||
|
}
|
||||||
|
);
|
||||||
23
src/routes/storyboard/uploadImage.ts
Normal file
23
src/routes/storyboard/uploadImage.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 上传对话图片
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
base64Data: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { base64Data, projectId } = req.body;
|
||||||
|
const savePath = `/${projectId}/chat/${uuid()}.jpg`;
|
||||||
|
await u.oss.writeFile(savePath, Buffer.from(base64Data.match(/base64,([A-Za-z0-9+/=]+)/)[1] ?? "", "base64"));
|
||||||
|
const url = await u.oss.getFileUrl(savePath);
|
||||||
|
res.status(200).send(success(url));
|
||||||
|
}
|
||||||
|
);
|
||||||
53
src/routes/task/getTaskApi.ts
Normal file
53
src/routes/task/getTaskApi.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { number, z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export default router.get(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectName: z.string(),
|
||||||
|
taskName: z.string(),
|
||||||
|
state: z.string(),
|
||||||
|
page: z.number(),
|
||||||
|
limit: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectName, taskName, state, page = 1, limit = 10 }: any = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const data = await u
|
||||||
|
.db("t_taskList")
|
||||||
|
.andWhere((qb) => {
|
||||||
|
if (projectName) {
|
||||||
|
qb.andWhere("t_taskList.projectName", projectName);
|
||||||
|
}
|
||||||
|
if (taskName) {
|
||||||
|
qb.andWhere("t_taskList.name", taskName);
|
||||||
|
}
|
||||||
|
if (state) {
|
||||||
|
qb.andWhere("t_taskList.state", state);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.select("*")
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit);
|
||||||
|
const totalQuery = (await u
|
||||||
|
.db("t_taskList")
|
||||||
|
.andWhere((qb) => {
|
||||||
|
if (projectName) {
|
||||||
|
qb.andWhere("t_taskList.projectName", projectName);
|
||||||
|
}
|
||||||
|
if (taskName) {
|
||||||
|
qb.andWhere("t_taskList.name", taskName);
|
||||||
|
}
|
||||||
|
if (state) {
|
||||||
|
qb.andWhere("t_taskList.state", state);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.count("* as total")
|
||||||
|
.first()) as any;
|
||||||
|
res.status(200).send(success({ data, total: totalQuery?.total }));
|
||||||
|
}
|
||||||
|
);
|
||||||
18
src/routes/task/taskDetails.ts
Normal file
18
src/routes/task/taskDetails.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
taskId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { taskId } = req.body;
|
||||||
|
const data = await u.db("t_taskList").where("id", taskId).select("*").first();
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
13
src/routes/user/getUser.ts
Normal file
13
src/routes/user/getUser.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取用户
|
||||||
|
export default router.get("/", async (req, res) => {
|
||||||
|
const data = await u.db("t_user").select("*").first();
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
});
|
||||||
46
src/routes/video/addVideo.ts
Normal file
46
src/routes/video/addVideo.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 新增视频
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
scriptId: z.number(),
|
||||||
|
type: z.string(),
|
||||||
|
resolution: z.string(),
|
||||||
|
filePath: z.array(z.string()),
|
||||||
|
duration: z.number(),
|
||||||
|
prompt: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { scriptId, type, resolution, filePath, duration, prompt } = req.body;
|
||||||
|
|
||||||
|
let model = "";
|
||||||
|
if (type.includes("doubao")) {
|
||||||
|
model = "doubao-seedance-1-5-pro-251215";
|
||||||
|
}
|
||||||
|
if (type.includes("sora")) {
|
||||||
|
model = "sora-2";
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstFrame = new URL(filePath[0]).pathname;
|
||||||
|
let storyboardImgs = filePath.map((path: string) => new URL(path).pathname);
|
||||||
|
|
||||||
|
await u.db("t_video").insert({
|
||||||
|
time: duration,
|
||||||
|
resolution: resolution,
|
||||||
|
prompt: prompt,
|
||||||
|
model: type,
|
||||||
|
firstFrame: firstFrame,
|
||||||
|
storyboardImgs: JSON.stringify(storyboardImgs),
|
||||||
|
scriptId: scriptId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({ message: "新增视频成功" }));
|
||||||
|
}
|
||||||
|
);
|
||||||
90
src/routes/video/addVideoConfig.ts
Normal file
90
src/routes/video/addVideoConfig.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 图片项schema
|
||||||
|
const imageItemSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
filePath: z.string(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
}).nullable();
|
||||||
|
|
||||||
|
// 新增视频配置
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
scriptId: z.number(),
|
||||||
|
projectId: z.number(),
|
||||||
|
manufacturer: z.string(),
|
||||||
|
mode: z.enum(["startEnd", "multi", "single"]),
|
||||||
|
startFrame: imageItemSchema.optional(),
|
||||||
|
endFrame: imageItemSchema.optional(),
|
||||||
|
images: z.array(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
filePath: z.string(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
})).optional(),
|
||||||
|
resolution: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const {
|
||||||
|
scriptId,
|
||||||
|
projectId,
|
||||||
|
manufacturer,
|
||||||
|
mode,
|
||||||
|
startFrame,
|
||||||
|
endFrame,
|
||||||
|
images,
|
||||||
|
resolution,
|
||||||
|
duration,
|
||||||
|
prompt
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 生成新ID
|
||||||
|
const maxIdResult = await u.db("t_videoConfig").max("id as maxId").first();
|
||||||
|
const newId = (maxIdResult?.maxId || 0) + 1;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 插入数据
|
||||||
|
await u.db("t_videoConfig").insert({
|
||||||
|
id: newId,
|
||||||
|
scriptId,
|
||||||
|
projectId,
|
||||||
|
manufacturer,
|
||||||
|
mode,
|
||||||
|
startFrame: startFrame ? JSON.stringify(startFrame) : null,
|
||||||
|
endFrame: endFrame ? JSON.stringify(endFrame) : null,
|
||||||
|
images: images ? JSON.stringify(images) : null,
|
||||||
|
resolution,
|
||||||
|
duration,
|
||||||
|
prompt: prompt || "",
|
||||||
|
selectedResultId: null,
|
||||||
|
createTime: now,
|
||||||
|
updateTime: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success({
|
||||||
|
message: "新增视频配置成功",
|
||||||
|
data: {
|
||||||
|
id: newId,
|
||||||
|
scriptId,
|
||||||
|
projectId,
|
||||||
|
manufacturer,
|
||||||
|
mode,
|
||||||
|
startFrame,
|
||||||
|
endFrame,
|
||||||
|
images: images || [],
|
||||||
|
resolution,
|
||||||
|
duration,
|
||||||
|
prompt: prompt || "",
|
||||||
|
selectedResultId: null,
|
||||||
|
createdAt: new Date(now).toISOString(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
79
src/routes/video/deleteVideoConfig.ts
Normal file
79
src/routes/video/deleteVideoConfig.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success, error } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 删除视频配置
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id } = req.body;
|
||||||
|
|
||||||
|
// 查询配置是否存在
|
||||||
|
const config = await u.db("t_videoConfig").where({ id }).first();
|
||||||
|
if (!config) {
|
||||||
|
return res.status(404).send(error("视频配置不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取关联的视频生成结果(通过scriptId和配置关联)
|
||||||
|
const videoResults = await u.db("t_video").where("configId", id).select("*");
|
||||||
|
|
||||||
|
// 收集需要删除的文件路径
|
||||||
|
const filesToDelete: string[] = [];
|
||||||
|
|
||||||
|
// 删除视频结果的文件
|
||||||
|
for (const result of videoResults) {
|
||||||
|
if (result.filePath) {
|
||||||
|
filesToDelete.push(result.filePath);
|
||||||
|
}
|
||||||
|
if (result.firstFrame) {
|
||||||
|
filesToDelete.push(result.firstFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
for (const filePath of filesToDelete) {
|
||||||
|
let rootDir: string;
|
||||||
|
if (typeof process.versions?.electron !== "undefined") {
|
||||||
|
const { app } = require("electron");
|
||||||
|
const userDataDir: string = app.getPath("userData");
|
||||||
|
rootDir = path.join(userDataDir, "uploads");
|
||||||
|
} else {
|
||||||
|
rootDir = path.join(process.cwd(), "uploads");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const absolutePath = path.join(rootDir, filePath);
|
||||||
|
if (fs.existsSync(absolutePath)) {
|
||||||
|
fs.unlinkSync(absolutePath);
|
||||||
|
console.log("[删除视频配置] 删除文件:", absolutePath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[删除视频配置] 删除文件失败:", filePath, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除数据库中的视频结果记录
|
||||||
|
await u.db("t_video").where("configId", id).delete();
|
||||||
|
|
||||||
|
// 删除配置记录
|
||||||
|
await u.db("t_videoConfig").where({ id }).delete();
|
||||||
|
|
||||||
|
res.status(200).send(
|
||||||
|
success({
|
||||||
|
message: "删除视频配置成功",
|
||||||
|
data: {
|
||||||
|
deletedConfigId: id,
|
||||||
|
deletedResultsCount: videoResults.length,
|
||||||
|
deletedFilesCount: filesToDelete.length,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
93
src/routes/video/generatePrompt.ts
Normal file
93
src/routes/video/generatePrompt.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
type GenerateMode = "startEnd" | "multi" | "single";
|
||||||
|
|
||||||
|
const getSystemPrompt = async (mode: GenerateMode): Promise<string> => {
|
||||||
|
const promptsList = await u.db("t_prompts").where("code", "in", ["video-startEnd", "video-multi", "video-single", "video-main"]);
|
||||||
|
const errPrompts = "不论用户说什么,请直接输出AI配置异常";
|
||||||
|
const getPromptValue = (code: string): string => {
|
||||||
|
const item = promptsList.find((p) => p.code === code);
|
||||||
|
return item?.customValue ?? item?.defaultValue ?? errPrompts;
|
||||||
|
};
|
||||||
|
const startEnd = getPromptValue("video-startEnd");
|
||||||
|
const multi = getPromptValue("video-multi");
|
||||||
|
const single = getPromptValue("video-single");
|
||||||
|
const main = getPromptValue("video-main");
|
||||||
|
|
||||||
|
const modeDescriptions: Record<GenerateMode, string> = {
|
||||||
|
startEnd: startEnd,
|
||||||
|
multi: multi,
|
||||||
|
single: single,
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${main}\n\n${modeDescriptions[mode]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModeDescription = (mode: GenerateMode): string => {
|
||||||
|
const map: Record<GenerateMode, string> = {
|
||||||
|
startEnd: "首尾帧模式",
|
||||||
|
multi: "宫格模式",
|
||||||
|
single: "单图模式",
|
||||||
|
};
|
||||||
|
return map[mode];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
images: z.array(
|
||||||
|
z.object({
|
||||||
|
filePath: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
prompt: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
type: z.enum(["startEnd", "multi", "single"]).optional(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { prompt, images, duration, type = "single" } = req.body;
|
||||||
|
const mode = type as GenerateMode;
|
||||||
|
|
||||||
|
const model = await u.ai.text({});
|
||||||
|
|
||||||
|
const imagePrompts = images.map((i: { filePath: string; prompt: string }, index: number) => `Image ${index + 1}: ${i.prompt}`).join("\n");
|
||||||
|
|
||||||
|
const shotCount = images.length;
|
||||||
|
const avgDuration = (parseFloat(duration) / shotCount).toFixed(1);
|
||||||
|
|
||||||
|
const result = await model!.invoke({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: await getSystemPrompt(mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Mode: ${getModeDescription(mode)}
|
||||||
|
|
||||||
|
Reference Images:
|
||||||
|
${imagePrompts}
|
||||||
|
|
||||||
|
Script:
|
||||||
|
${prompt}
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- Total Duration: ${duration}s
|
||||||
|
- Shot Count: ${shotCount}
|
||||||
|
- Average Duration: ${avgDuration}s per shot
|
||||||
|
|
||||||
|
Generate storyboard prompts:`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(success(result.text));
|
||||||
|
},
|
||||||
|
);
|
||||||
178
src/routes/video/generateVideo.ts
Normal file
178
src/routes/video/generateVideo.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { error, success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 生成视频
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
projectId: z.number(),
|
||||||
|
scriptId: z.number(),
|
||||||
|
configId: z.number().optional(), // 关联的视频配置ID
|
||||||
|
type: z.string().optional(),
|
||||||
|
resolution: z.string(),
|
||||||
|
filePath: z.array(z.string()),
|
||||||
|
duration: z.number(),
|
||||||
|
prompt: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { type, scriptId, projectId, configId, resolution, filePath, duration, prompt } = req.body;
|
||||||
|
|
||||||
|
// 参数校验
|
||||||
|
if (type === "volcengine") {
|
||||||
|
if (duration < 4 || duration > 12) {
|
||||||
|
return res.status(400).send(error("视频时长需在4-12秒之间"));
|
||||||
|
}
|
||||||
|
if (!["480p", "720p", "1080p"].includes(resolution)) {
|
||||||
|
return res.status(400).send(error("视频分辨率不正确"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "runninghub") {
|
||||||
|
if (duration !== 10 && duration !== 15) {
|
||||||
|
return res.status(400).send(error("视频时长只能是10秒或15秒"));
|
||||||
|
}
|
||||||
|
if (resolution !== "9:16" && resolution !== "16:9") {
|
||||||
|
return res.status(400).send(error("视频分辨率不正确"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤掉空值
|
||||||
|
let fileUrl = filePath.filter((p: string) => p && p.trim() !== "");
|
||||||
|
|
||||||
|
if (fileUrl.length === 0) {
|
||||||
|
return res.status(400).send(error("请至少选择一张图片"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件路径,如果是 base64 则上传到 OSS
|
||||||
|
if (fileUrl.length === 1) {
|
||||||
|
const match = fileUrl[0].match(/base64,([A-Za-z0-9+/=]+)/);
|
||||||
|
if (match && match.length >= 2) {
|
||||||
|
const imagePath = `/${projectId}/assets/${uuidv4()}.jpg`;
|
||||||
|
const buffer = Buffer.from(match[1], "base64");
|
||||||
|
await u.oss.writeFile(imagePath, buffer);
|
||||||
|
fileUrl = [await u.oss.getFileUrl(imagePath)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取路径名的辅助函数
|
||||||
|
const getPathname = (url: string): string => {
|
||||||
|
// 如果是完整 URL,提取 pathname
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
|
return new URL(url).pathname;
|
||||||
|
}
|
||||||
|
// 否则认为已经是路径
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 校验文件是否存在
|
||||||
|
const fileExistsResults = await Promise.all(
|
||||||
|
fileUrl.map(async (url: string) => {
|
||||||
|
const path = getPathname(url);
|
||||||
|
return u.oss.fileExists(path);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fileExistsResults.every(Boolean)) {
|
||||||
|
return res.status(400).send(error("选择分镜文件不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstFrame = getPathname(fileUrl[0]);
|
||||||
|
const storyboardImgs = fileUrl.map((path: string) => getPathname(path));
|
||||||
|
const savePath = `/${projectId}/video/${uuidv4()}.mp4`;
|
||||||
|
|
||||||
|
// 先插入记录,state 默认为 0
|
||||||
|
const [videoId] = await u.db("t_video").insert({
|
||||||
|
scriptId,
|
||||||
|
configId: configId || null, // 关联的视频配置ID
|
||||||
|
time: duration,
|
||||||
|
resolution,
|
||||||
|
prompt,
|
||||||
|
firstFrame,
|
||||||
|
storyboardImgs: JSON.stringify(storyboardImgs),
|
||||||
|
filePath: savePath,
|
||||||
|
state: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 立即返回,不等待视频生成
|
||||||
|
res.status(200).send(success({ id: videoId, configId: configId || null }));
|
||||||
|
|
||||||
|
// 异步生成视频
|
||||||
|
generateVideoAsync(videoId, projectId, fileUrl, savePath, prompt, duration, resolution, type);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步生成视频
|
||||||
|
async function generateVideoAsync(
|
||||||
|
videoId: number,
|
||||||
|
projectId: number,
|
||||||
|
fileUrl: string[],
|
||||||
|
savePath: string,
|
||||||
|
prompt: string,
|
||||||
|
duration: number,
|
||||||
|
resolution: string,
|
||||||
|
type?: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const projectData = await u.db("t_project").where("id", projectId).select("artStyle").first();
|
||||||
|
|
||||||
|
// 提取路径名的辅助函数
|
||||||
|
const getPathname = (url: string): string => {
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
|
return new URL(url).pathname;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageBase64 = await Promise.all(
|
||||||
|
fileUrl.map((path: string) => {
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
return u.oss.getImageBase64(getPathname(path));
|
||||||
|
}
|
||||||
|
// 如果是相对路径,直接获取
|
||||||
|
return u.oss.getImageBase64(path);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputPrompt = `
|
||||||
|
请完全参照以下内容生成视频:
|
||||||
|
${prompt}
|
||||||
|
重要强调:
|
||||||
|
风格高度保持${projectData?.artStyle || "CG"}风格,保证人物一致性
|
||||||
|
1. 视频整体风格、色调、光影、人脸五官与参考图片保持高度一致
|
||||||
|
2. 保证视频连贯性、前后无矛盾
|
||||||
|
3. 关键人物在画面中全部清晰显示,不得被遮挡、缺失或省略
|
||||||
|
4. 画面真实、细致,无畸形、无模糊、无杂物、无多余人物、无文字、水印、logo
|
||||||
|
`;
|
||||||
|
|
||||||
|
const videoPath = await u.ai.generateVideo(
|
||||||
|
{
|
||||||
|
imageBase64,
|
||||||
|
savePath,
|
||||||
|
prompt: inputPrompt,
|
||||||
|
duration: duration as any,
|
||||||
|
aspectRatio: resolution as any,
|
||||||
|
},
|
||||||
|
type!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (videoPath) {
|
||||||
|
// 生成成功,更新状态为 1
|
||||||
|
await u.db("t_video").where("id", videoId).update({
|
||||||
|
filePath: videoPath,
|
||||||
|
state: 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 生成失败,更新状态为 -1
|
||||||
|
await u.db("t_video").where("id", videoId).update({ state: -1 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`视频生成失败 videoId=${videoId}:`, err);
|
||||||
|
await u.db("t_video").where("id", videoId).update({ state: -1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/routes/video/getManufacturer.ts
Normal file
21
src/routes/video/getManufacturer.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取厂商
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
userId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_config").where("userId", userId).select("manufacturer", "model");
|
||||||
|
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
}
|
||||||
|
);
|
||||||
83
src/routes/video/getVideo.ts
Normal file
83
src/routes/video/getVideo.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
interface TempAsset {
|
||||||
|
videoId: number;
|
||||||
|
filePath: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
scriptId: z.number(),
|
||||||
|
specifyIds: z.array(z.number()).optional(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { scriptId, specifyIds } = req.body;
|
||||||
|
|
||||||
|
const videos = await u
|
||||||
|
.db("t_video")
|
||||||
|
.where("scriptId", scriptId)
|
||||||
|
.modify((qb) => {
|
||||||
|
if (specifyIds && specifyIds.length) {
|
||||||
|
qb.whereIn("id", specifyIds);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.select("id", "configId", "time", "resolution", "prompt", "firstFrame", "filePath", "storyboardImgs", "model", "scriptId", "state");
|
||||||
|
// const videoIds: number[] = videos.map((video: any) => (typeof video.id === "string" ? parseInt(video.id) : video.id));
|
||||||
|
|
||||||
|
// let tempAssets: TempAsset[] = await u
|
||||||
|
// .db("t_tempAssets")
|
||||||
|
// .whereIn("videoId", videoIds)
|
||||||
|
// .whereNot("filePath", "")
|
||||||
|
// .select("videoId", "filePath", "type");
|
||||||
|
|
||||||
|
// tempAssets = await Promise.all(
|
||||||
|
// tempAssets.map(async (asset) => {
|
||||||
|
// const signedFilePath = asset.filePath ? await u.oss.getFileUrl(asset.filePath) : "";
|
||||||
|
// return {
|
||||||
|
// ...asset,
|
||||||
|
// filePath: signedFilePath,
|
||||||
|
// };
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const tempAssetsMap: Record<number, TempAsset[]> = {};
|
||||||
|
// tempAssets.forEach((asset) => {
|
||||||
|
// if (!tempAssetsMap[asset.videoId]) {
|
||||||
|
// tempAssetsMap[asset.videoId] = [];
|
||||||
|
// }
|
||||||
|
// tempAssetsMap[asset.videoId]!.push(asset);
|
||||||
|
// });
|
||||||
|
|
||||||
|
const data = await Promise.all(
|
||||||
|
videos.map(async (video: any) => {
|
||||||
|
let storyboardImgs: string[] = [];
|
||||||
|
if (video.storyboardImgs) {
|
||||||
|
try {
|
||||||
|
storyboardImgs = Array.isArray(video.storyboardImgs) ? video.storyboardImgs : JSON.parse(video.storyboardImgs);
|
||||||
|
} catch (err) {
|
||||||
|
storyboardImgs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const signedStoryboardImgs = await Promise.all(storyboardImgs.map((img) => (img ? u.oss.getFileUrl(img) : "")));
|
||||||
|
const signedFilePath = video.filePath ? await u.oss.getFileUrl(video.filePath) : "";
|
||||||
|
const signedFirstFrame = video.firstFrame ? await u.oss.getFileUrl(video.firstFrame) : "";
|
||||||
|
const videoId = typeof video.id === "string" ? parseInt(video.id) : video.id;
|
||||||
|
return {
|
||||||
|
...video,
|
||||||
|
filePath: signedFilePath,
|
||||||
|
firstFrame: signedFirstFrame,
|
||||||
|
storyboardImgs: signedStoryboardImgs,
|
||||||
|
// tempAssets: tempAssetsMap[videoId] || [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
res.status(200).send(success(data));
|
||||||
|
},
|
||||||
|
);
|
||||||
41
src/routes/video/getVideoConfigs.ts
Normal file
41
src/routes/video/getVideoConfigs.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取视频配置列表
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
scriptId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { scriptId } = req.body;
|
||||||
|
|
||||||
|
// 查询该脚本下的所有视频配置
|
||||||
|
const configs = await u.db("t_videoConfig")
|
||||||
|
.where({ scriptId })
|
||||||
|
.orderBy("createTime", "desc");
|
||||||
|
|
||||||
|
// 解析 JSON 字段
|
||||||
|
const result = configs.map((config: any) => ({
|
||||||
|
id: config.id,
|
||||||
|
scriptId: config.scriptId,
|
||||||
|
projectId: config.projectId,
|
||||||
|
manufacturer: config.manufacturer,
|
||||||
|
mode: config.mode,
|
||||||
|
startFrame: config.startFrame ? JSON.parse(config.startFrame) : null,
|
||||||
|
endFrame: config.endFrame ? JSON.parse(config.endFrame) : null,
|
||||||
|
images: config.images ? JSON.parse(config.images) : [],
|
||||||
|
resolution: config.resolution,
|
||||||
|
duration: config.duration,
|
||||||
|
prompt: config.prompt || "",
|
||||||
|
selectedResultId: config.selectedResultId,
|
||||||
|
createdAt: config.createTime ? new Date(config.createTime).toISOString() : new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).send(success(result));
|
||||||
|
},
|
||||||
|
);
|
||||||
31
src/routes/video/getVideoModel.ts
Normal file
31
src/routes/video/getVideoModel.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取视频模型
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
userId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
const data = await u.db("t_config").where("userId", userId).select("model");
|
||||||
|
const modelData = [];
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
if (item.model?.includes("sora")) {
|
||||||
|
modelData.push("sora");
|
||||||
|
}
|
||||||
|
if (item.model?.includes("doubao")) {
|
||||||
|
modelData.push("doubao");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(success(modelData));
|
||||||
|
}
|
||||||
|
);
|
||||||
83
src/routes/video/getVideoStoryboards.ts
Normal file
83
src/routes/video/getVideoStoryboards.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
interface Storyboard {
|
||||||
|
id: number;
|
||||||
|
storyboardName: string;
|
||||||
|
filePath: string;
|
||||||
|
prompt: string;
|
||||||
|
videoPrompt: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
interface StoryboardList {
|
||||||
|
id: number;
|
||||||
|
scriptName: string;
|
||||||
|
storyboard: Storyboard[];
|
||||||
|
}
|
||||||
|
interface RawRow {
|
||||||
|
scriptId: number;
|
||||||
|
scriptName: string;
|
||||||
|
storyboardId: number | null;
|
||||||
|
storyboardName: string | null;
|
||||||
|
filePath: string | null;
|
||||||
|
prompt: string | null;
|
||||||
|
videoPrompt: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频分镜
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
scriptId: z.number(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { scriptId } = req.body;
|
||||||
|
|
||||||
|
const rawData: RawRow[] = await u
|
||||||
|
.db("t_script")
|
||||||
|
.leftJoin("t_assets", "t_assets.scriptId", "t_script.id")
|
||||||
|
.where("t_script.id", scriptId)
|
||||||
|
.where("t_assets.type", "分镜")
|
||||||
|
.select([
|
||||||
|
"t_script.id as scriptId",
|
||||||
|
"t_script.name as scriptName",
|
||||||
|
"t_assets.id as storyboardId",
|
||||||
|
"t_assets.name as storyboardName",
|
||||||
|
"t_assets.filePath",
|
||||||
|
"t_assets.videoPrompt",
|
||||||
|
"t_assets.prompt",
|
||||||
|
"t_assets.duration",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 分组整理
|
||||||
|
const result: StoryboardList[] = [];
|
||||||
|
const map = new Map<number, StoryboardList>();
|
||||||
|
|
||||||
|
for (const row of rawData) {
|
||||||
|
if (!map.has(row.scriptId)) {
|
||||||
|
const script: StoryboardList = {
|
||||||
|
id: row.scriptId,
|
||||||
|
scriptName: row.scriptName,
|
||||||
|
storyboard: [],
|
||||||
|
};
|
||||||
|
map.set(row.scriptId, script);
|
||||||
|
result.push(script);
|
||||||
|
}
|
||||||
|
if (row.storyboardId) {
|
||||||
|
map.get(row.scriptId)!.storyboard.push({
|
||||||
|
id: row.storyboardId,
|
||||||
|
storyboardName: row.storyboardName ?? "",
|
||||||
|
filePath: await u.oss.getFileUrl(row.filePath ?? ""),
|
||||||
|
prompt: row.prompt ?? "",
|
||||||
|
videoPrompt: row.videoPrompt ?? "",
|
||||||
|
duration: row.duration ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.status(200).send(success(result));
|
||||||
|
}
|
||||||
|
);
|
||||||
25
src/routes/video/reviseVideoStoryboards.ts
Normal file
25
src/routes/video/reviseVideoStoryboards.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 修改视频分镜参数
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
storyboardId: z.number(),
|
||||||
|
prompt: z.string(),
|
||||||
|
duration: z.string(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { storyboardId, prompt, duration } = req.body;
|
||||||
|
|
||||||
|
await u.db("t_assets").where("id", storyboardId).update({
|
||||||
|
prompt,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
res.status(200).send({ message: "修改成功" });
|
||||||
|
}
|
||||||
|
);
|
||||||
109
src/routes/video/saveVideo.ts
Normal file
109
src/routes/video/saveVideo.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { success } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 保存视频
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
filePath: z.string(),
|
||||||
|
storyboardImgs: z.array(z.string()).optional().nullable(),
|
||||||
|
prompt: z.string().optional().nullable(),
|
||||||
|
model: z.string().optional().nullable(),
|
||||||
|
time: z.number().optional().nullable(),
|
||||||
|
resolution: z.string().optional().nullable(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { filePath, id, time, resolution, storyboardImgs, prompt, model } = req.body;
|
||||||
|
|
||||||
|
let savePath: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
savePath = new URL(filePath).pathname;
|
||||||
|
} catch {
|
||||||
|
savePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldVideo = await u.db("t_video").where("id", id).select("filePath", "scriptId").first();
|
||||||
|
|
||||||
|
let trimmedImgs: string[] = [];
|
||||||
|
let firstFramePath: string | undefined;
|
||||||
|
|
||||||
|
if (storyboardImgs && storyboardImgs.length > 0) {
|
||||||
|
trimmedImgs = storyboardImgs.map((img: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(img).pathname;
|
||||||
|
} catch {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
firstFramePath = trimmedImgs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldVideo) {
|
||||||
|
await u.db("t_video").insert({
|
||||||
|
id,
|
||||||
|
filePath: savePath,
|
||||||
|
time,
|
||||||
|
resolution,
|
||||||
|
model,
|
||||||
|
firstFrame: firstFramePath,
|
||||||
|
storyboardImgs: JSON.stringify(trimmedImgs),
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
return res.status(200).send({ message: "保存视频成功" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVideo.filePath !== savePath) {
|
||||||
|
// 1. 删除临时表中属于新视频的资源
|
||||||
|
const newTempVideo = await u.db("t_tempAssets").where({ videoId: id, filePath: savePath }).first();
|
||||||
|
|
||||||
|
if (newTempVideo) {
|
||||||
|
await u.db("t_tempAssets").where({ videoId: id, filePath: savePath }).del();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查旧视频是否已经在临时表,不在则插入
|
||||||
|
const oldTempVideo = await u.db("t_tempAssets").where({ videoId: id, filePath: oldVideo.filePath }).first();
|
||||||
|
if (!oldTempVideo) {
|
||||||
|
await u.db("t_tempAssets").insert({
|
||||||
|
videoId: id,
|
||||||
|
type: "视频",
|
||||||
|
filePath: oldVideo.filePath,
|
||||||
|
scriptId: oldVideo.scriptId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新视频表
|
||||||
|
await u
|
||||||
|
.db("t_video")
|
||||||
|
.where("id", id)
|
||||||
|
.update({
|
||||||
|
filePath: savePath,
|
||||||
|
time,
|
||||||
|
resolution,
|
||||||
|
model,
|
||||||
|
firstFrame: firstFramePath,
|
||||||
|
storyboardImgs: JSON.stringify(trimmedImgs),
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await u
|
||||||
|
.db("t_video")
|
||||||
|
.where("id", id)
|
||||||
|
.update({
|
||||||
|
time,
|
||||||
|
resolution,
|
||||||
|
model,
|
||||||
|
firstFrame: firstFramePath,
|
||||||
|
storyboardImgs: JSON.stringify(trimmedImgs),
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send({ message: "保存视频成功" });
|
||||||
|
}
|
||||||
|
);
|
||||||
70
src/routes/video/upDateVideoConfig.ts
Normal file
70
src/routes/video/upDateVideoConfig.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import express from "express";
|
||||||
|
import u from "@/utils";
|
||||||
|
import { success, error } from "@/lib/responseFormat";
|
||||||
|
import { validateFields } from "@/middleware/middleware";
|
||||||
|
import { z } from "zod";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 更新视频配置
|
||||||
|
export default router.post(
|
||||||
|
"/",
|
||||||
|
validateFields({
|
||||||
|
id: z.number(),
|
||||||
|
resolution: z.string().optional(),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
selectedResultId: z.number().nullable().optional(),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { id, resolution, duration, prompt, selectedResultId } = req.body;
|
||||||
|
|
||||||
|
// 检查配置是否存在
|
||||||
|
const existingConfig = await u.db("t_videoConfig").where({ id }).first();
|
||||||
|
if (!existingConfig) {
|
||||||
|
return res.status(404).send(error("视频配置不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建更新对象
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
|
updateTime: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resolution !== undefined) {
|
||||||
|
updateData.resolution = resolution;
|
||||||
|
}
|
||||||
|
if (duration !== undefined) {
|
||||||
|
updateData.duration = duration;
|
||||||
|
}
|
||||||
|
if (prompt !== undefined) {
|
||||||
|
updateData.prompt = prompt;
|
||||||
|
}
|
||||||
|
if (selectedResultId !== undefined) {
|
||||||
|
updateData.selectedResultId = selectedResultId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新数据
|
||||||
|
await u.db("t_videoConfig").where({ id }).update(updateData);
|
||||||
|
|
||||||
|
// 获取更新后的数据
|
||||||
|
const updatedConfig = await u.db("t_videoConfig").where({ id }).first();
|
||||||
|
|
||||||
|
res.status(200).send(success({
|
||||||
|
message: "更新视频配置成功",
|
||||||
|
data: {
|
||||||
|
id: updatedConfig.id,
|
||||||
|
scriptId: updatedConfig.scriptId,
|
||||||
|
projectId: updatedConfig.projectId,
|
||||||
|
manufacturer: updatedConfig.manufacturer,
|
||||||
|
mode: updatedConfig.mode,
|
||||||
|
startFrame: updatedConfig.startFrame ? JSON.parse(updatedConfig.startFrame) : null,
|
||||||
|
endFrame: updatedConfig.endFrame ? JSON.parse(updatedConfig.endFrame) : null,
|
||||||
|
images: updatedConfig.images ? JSON.parse(updatedConfig.images) : [],
|
||||||
|
resolution: updatedConfig.resolution,
|
||||||
|
duration: updatedConfig.duration,
|
||||||
|
prompt: updatedConfig.prompt,
|
||||||
|
selectedResultId: updatedConfig.selectedResultId,
|
||||||
|
createdAt: new Date(updatedConfig.createTime).toISOString(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
166
src/types/database.d.ts
vendored
Normal file
166
src/types/database.d.ts
vendored
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
// @db-hash b6b4d8cdc25a2f4d60f1c239cd7e7060
|
||||||
|
//该文件由脚本自动生成,请勿手动修改
|
||||||
|
|
||||||
|
export interface t_assets {
|
||||||
|
'duration'?: string | null;
|
||||||
|
'episode'?: string | null;
|
||||||
|
'filePath'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'intro'?: string | null;
|
||||||
|
'name'?: string | null;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
'prompt'?: string | null;
|
||||||
|
'remark'?: string | null;
|
||||||
|
'scriptId'?: number | null;
|
||||||
|
'segmentId'?: number | null;
|
||||||
|
'shotIndex'?: number | null;
|
||||||
|
'state'?: string | null;
|
||||||
|
'type'?: string | null;
|
||||||
|
'videoPrompt'?: string | null;
|
||||||
|
}
|
||||||
|
export interface t_chatHistory {
|
||||||
|
'data'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'novel'?: string | null;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
'type'?: string | null;
|
||||||
|
}
|
||||||
|
export interface t_config {
|
||||||
|
'apiKey'?: string | null;
|
||||||
|
'baseUrl'?: string | null;
|
||||||
|
'createTime'?: number | null;
|
||||||
|
'id'?: number;
|
||||||
|
'index'?: number | null;
|
||||||
|
'manufacturer'?: string | null;
|
||||||
|
'model'?: string | null;
|
||||||
|
'name'?: string | null;
|
||||||
|
'type'?: string | null;
|
||||||
|
'userId'?: number | null;
|
||||||
|
}
|
||||||
|
export interface t_image {
|
||||||
|
'assetsId'?: number | null;
|
||||||
|
'filePath'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
'scriptId'?: number | null;
|
||||||
|
'state'?: string | null;
|
||||||
|
'type'?: string | null;
|
||||||
|
'videoId'?: number | null;
|
||||||
|
}
|
||||||
|
export interface t_novel {
|
||||||
|
'chapter'?: string | null;
|
||||||
|
'chapterData'?: string | null;
|
||||||
|
'chapterIndex'?: number | null;
|
||||||
|
'createTime'?: number | null;
|
||||||
|
'id'?: number;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
'reel'?: string | null;
|
||||||
|
}
|
||||||
|
export interface t_outline {
|
||||||
|
'data'?: string | null;
|
||||||
|
'episode'?: number | null;
|
||||||
|
'id'?: number;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
}
|
||||||
|
export interface t_project {
|
||||||
|
'artStyle'?: string | null;
|
||||||
|
'createTime'?: number | null;
|
||||||
|
'id'?: number | null;
|
||||||
|
'intro'?: string | null;
|
||||||
|
'name'?: string | null;
|
||||||
|
'type'?: string | null;
|
||||||
|
'userId'?: number | null;
|
||||||
|
'videoRatio'?: string | null;
|
||||||
|
}
|
||||||
|
export interface t_prompts {
|
||||||
|
'code'?: string | null;
|
||||||
|
'customValue'?: string | null;
|
||||||
|
'defaultValue'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'name'?: string | null;
|
||||||
|
'parentCode'?: string | null;
|
||||||
|
'type'?: string | null;
|
||||||
|
}
|
||||||
|
export interface t_script {
|
||||||
|
'content'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'name'?: string | null;
|
||||||
|
'outlineId'?: number | null;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
}
|
||||||
|
export interface t_setting {
|
||||||
|
'id'?: number;
|
||||||
|
'imageModel'?: string | null;
|
||||||
|
'languageModel'?: string | null;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
'tokenKey'?: string | null;
|
||||||
|
'userId'?: number | null;
|
||||||
|
}
|
||||||
|
export interface t_storyline {
|
||||||
|
'content'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'name'?: string | null;
|
||||||
|
'novelIds'?: string | null;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
}
|
||||||
|
export interface t_taskList {
|
||||||
|
'endTime'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'name'?: string | null;
|
||||||
|
'projectName'?: number | null;
|
||||||
|
'prompt'?: string | null;
|
||||||
|
'startTime'?: string | null;
|
||||||
|
'state'?: string | null;
|
||||||
|
}
|
||||||
|
export interface t_user {
|
||||||
|
'id'?: number;
|
||||||
|
'name'?: string | null;
|
||||||
|
'password'?: string | null;
|
||||||
|
}
|
||||||
|
export interface t_video {
|
||||||
|
'configId'?: number | null;
|
||||||
|
'filePath'?: string | null;
|
||||||
|
'firstFrame'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'model'?: string | null;
|
||||||
|
'prompt'?: string | null;
|
||||||
|
'resolution'?: string | null;
|
||||||
|
'scriptId'?: number | null;
|
||||||
|
'state'?: number | null;
|
||||||
|
'storyboardImgs'?: string | null;
|
||||||
|
'time'?: number | null;
|
||||||
|
}
|
||||||
|
export interface t_videoConfig {
|
||||||
|
'createTime'?: number | null;
|
||||||
|
'duration'?: number | null;
|
||||||
|
'endFrame'?: string | null;
|
||||||
|
'id'?: number;
|
||||||
|
'images'?: string | null;
|
||||||
|
'manufacturer'?: string | null;
|
||||||
|
'mode'?: string | null;
|
||||||
|
'projectId'?: number | null;
|
||||||
|
'prompt'?: string | null;
|
||||||
|
'resolution'?: string | null;
|
||||||
|
'scriptId'?: number | null;
|
||||||
|
'selectedResultId'?: number | null;
|
||||||
|
'startFrame'?: string | null;
|
||||||
|
'updateTime'?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DB {
|
||||||
|
"t_assets": t_assets;
|
||||||
|
"t_chatHistory": t_chatHistory;
|
||||||
|
"t_config": t_config;
|
||||||
|
"t_image": t_image;
|
||||||
|
"t_novel": t_novel;
|
||||||
|
"t_outline": t_outline;
|
||||||
|
"t_project": t_project;
|
||||||
|
"t_prompts": t_prompts;
|
||||||
|
"t_script": t_script;
|
||||||
|
"t_setting": t_setting;
|
||||||
|
"t_storyline": t_storyline;
|
||||||
|
"t_taskList": t_taskList;
|
||||||
|
"t_user": t_user;
|
||||||
|
"t_video": t_video;
|
||||||
|
"t_videoConfig": t_videoConfig;
|
||||||
|
}
|
||||||
19
src/utils.ts
Normal file
19
src/utils.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import db from "@/utils/db";
|
||||||
|
import oss from "@/utils/oss";
|
||||||
|
import * as ai from "@/utils/ai";
|
||||||
|
import editImage from "@/utils/editImage";
|
||||||
|
import number2Chinese from "@/utils/number2Chinese";
|
||||||
|
import deleteOutline from "@/utils/deleteOutline";
|
||||||
|
import getConfig from "./utils/getConfig";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
db,
|
||||||
|
oss,
|
||||||
|
ai,
|
||||||
|
editImage,
|
||||||
|
number2Chinese,
|
||||||
|
deleteOutline,
|
||||||
|
getConfig,
|
||||||
|
uuid,
|
||||||
|
};
|
||||||
542
src/utils/ai.ts
Normal file
542
src/utils/ai.ts
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import u from "@/utils";
|
||||||
|
import FormData from "form-data";
|
||||||
|
import axiosRetry from "axios-retry";
|
||||||
|
import { OpenAIChatModel, type OpenAIChatModelOptions } from "@aigne/openai";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
axiosRetry(axios, { retries: 3, retryDelay: () => 200 });
|
||||||
|
|
||||||
|
export const text = async (config: OpenAIChatModelOptions = {}) => {
|
||||||
|
const { model, apiKey, baseURL } = await u.getConfig("language");
|
||||||
|
return new OpenAIChatModel({
|
||||||
|
apiKey: apiKey ?? "",
|
||||||
|
baseURL: baseURL ?? "",
|
||||||
|
model: model ?? "gpt-4.1",
|
||||||
|
modelOptions: { temperature: 0.7 },
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ImageConfig {
|
||||||
|
systemPrompt?: string;
|
||||||
|
prompt: string;
|
||||||
|
imageBase64: string[];
|
||||||
|
size: "1K" | "2K" | "4K";
|
||||||
|
aspectRatio: string;
|
||||||
|
resType?: "url" | "b64";
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlToBase64 = async (url: string): Promise<string> => {
|
||||||
|
const res = await axios.get(url, { responseType: "arraybuffer" });
|
||||||
|
const base64 = Buffer.from(res.data).toString("base64");
|
||||||
|
const mimeType = res.headers["content-type"] || "image/png";
|
||||||
|
return `data:${mimeType};base64,${base64}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const pollTask = async (
|
||||||
|
queryFn: () => Promise<{ completed: boolean; imageUrl?: string; error?: string }>,
|
||||||
|
maxAttempts = 500,
|
||||||
|
interval = 2000,
|
||||||
|
): Promise<string> => {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await sleep(interval);
|
||||||
|
const { completed, imageUrl, error } = await queryFn();
|
||||||
|
if (error) throw new Error(error);
|
||||||
|
if (completed && imageUrl) return imageUrl;
|
||||||
|
}
|
||||||
|
throw new Error(`任务轮询超时,已尝试 ${maxAttempts} 次`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上传 base64 图片到 runninghub
|
||||||
|
const uploadBase64ToRunninghub = async (base64Image: string, apiKey: string, baseURL: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
apiKey = apiKey.replace("Bearer ", "");
|
||||||
|
// 移除 base64 前缀
|
||||||
|
const base64Data = base64Image.replace(/^data:image\/\w+;base64,/, "");
|
||||||
|
let buffer = Buffer.from(base64Data, "base64");
|
||||||
|
|
||||||
|
// 压缩图片到 5MB 以下
|
||||||
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
if (buffer.length > MAX_SIZE) {
|
||||||
|
let quality = 90;
|
||||||
|
|
||||||
|
while (buffer.length > MAX_SIZE && quality > 10) {
|
||||||
|
const compressed = await sharp(buffer).jpeg({ quality, mozjpeg: true }).toBuffer();
|
||||||
|
buffer = Buffer.from(compressed);
|
||||||
|
quality -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然超过限制,进一步调整尺寸
|
||||||
|
if (buffer.length > MAX_SIZE) {
|
||||||
|
const metadata = await sharp(buffer).metadata();
|
||||||
|
const scale = Math.sqrt(MAX_SIZE / buffer.length);
|
||||||
|
|
||||||
|
const resized = await sharp(buffer)
|
||||||
|
.resize({
|
||||||
|
width: Math.floor((metadata.width || 1920) * scale),
|
||||||
|
height: Math.floor((metadata.height || 1080) * scale),
|
||||||
|
fit: "inside",
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 80, mozjpeg: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
buffer = Buffer.from(resized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 FormData
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", buffer, {
|
||||||
|
filename: "image.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
const uploadRes = await axios.post(`https://www.runninghub.cn/openapi/v2/media/upload/binary`, formData, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadRes.data.code !== 0 || !uploadRes.data.data?.download_url) {
|
||||||
|
throw new Error(`图片上传失败: ${JSON.stringify(uploadRes.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploadRes.data.data.download_url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("上传图片时发生错误:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generators = {
|
||||||
|
volcengine: async (config: ImageConfig, apiKey: string, baseURL: string, model: string) => {
|
||||||
|
apiKey = apiKey.replace("Bearer ", "");
|
||||||
|
const res = await axios.post(
|
||||||
|
`https://api.volcengineapi.com/v1/images/generations`,
|
||||||
|
{ model, prompt: config.systemPrompt, image: config.imageBase64, size: config.size, watermark: false },
|
||||||
|
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
||||||
|
);
|
||||||
|
return res.data[0].url;
|
||||||
|
},
|
||||||
|
|
||||||
|
gemini: async (config: ImageConfig, apiKey: string, baseURL: string, model: string) => {
|
||||||
|
apiKey = apiKey.replace("Bearer ", "");
|
||||||
|
const messages = [
|
||||||
|
...(config.systemPrompt ? [{ role: "system", content: config.systemPrompt }] : []),
|
||||||
|
{ role: "user", content: config.prompt },
|
||||||
|
...config.imageBase64.map((img) => ({ role: "user", content: { image: img } })),
|
||||||
|
];
|
||||||
|
const res = await axios.post(
|
||||||
|
`${baseURL}/chat/completions`,
|
||||||
|
{ model, stream: false, messages, extra_body: { google: { image_config: { aspect_ratio: config.aspectRatio, image_size: config.size } } } },
|
||||||
|
{ headers: { Authorization: "Bearer " + apiKey } },
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.data.choices[0].message.content;
|
||||||
|
},
|
||||||
|
|
||||||
|
runninghub: async (config: ImageConfig, apiKey: string, baseURL: string) => {
|
||||||
|
apiKey = apiKey.replace("Bearer ", "");
|
||||||
|
const imageUrls = await Promise.all(config.imageBase64.map((base64Image) => uploadBase64ToRunninghub(base64Image, apiKey, baseURL)));
|
||||||
|
|
||||||
|
const endpoint = config.imageBase64.length === 0 ? "/openapi/v2/rhart-image-n-pro/text-to-image" : "/openapi/v2/rhart-image-n-pro/edit";
|
||||||
|
const taskRes = await axios.post(
|
||||||
|
`https://www.runninghub.cn${endpoint}`,
|
||||||
|
{ prompt: config.prompt, resolution: config.size, aspectRatio: config.aspectRatio, ...(imageUrls.length > 0 && { imageUrls }) },
|
||||||
|
{ headers: { Authorization: "Bearer " + apiKey } },
|
||||||
|
);
|
||||||
|
const taskId = taskRes.data.taskId;
|
||||||
|
if (!taskId) throw new Error(`任务创建失败,${JSON.stringify(taskRes.data)}`);
|
||||||
|
|
||||||
|
return pollTask(async () => {
|
||||||
|
const res = await axios.post(`https://www.runninghub.cn/task/openapi/outputs`, { taskId, apiKey: apiKey });
|
||||||
|
const { code, msg, data } = res.data;
|
||||||
|
if (code === 0 && msg === "success") return { completed: true, imageUrl: data?.[0]?.fileUrl };
|
||||||
|
if (code === 804 || code === 813) return { completed: false };
|
||||||
|
if (code === 805) return { completed: false, error: `任务失败: ${data?.[0]?.failedReason?.exception_message || "未知原因"}` };
|
||||||
|
return { completed: false, error: `未知状态: code=${code}, msg=${msg}` };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
apimart: async (config: ImageConfig, apiKey: string, baseURL: string, model: string) => {
|
||||||
|
apiKey = apiKey.replace("Bearer ", "");
|
||||||
|
const taskRes = await axios.post(
|
||||||
|
`https://api.apimart.ai/v1/images/generations`,
|
||||||
|
{ model: "gemini-3-pro-image-preview", prompt: config.prompt, size: config.aspectRatio, n: 1, resolution: config.size },
|
||||||
|
{ headers: { Authorization: apiKey } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taskRes.data.code !== 200 || !taskRes.data.data?.[0]?.task_id) throw new Error("任务创建失败: " + JSON.stringify(taskRes.data));
|
||||||
|
|
||||||
|
const taskId = taskRes.data.data[0].task_id;
|
||||||
|
return pollTask(async () => {
|
||||||
|
const res = await axios.get(`https://api.apimart.ai/v1/tasks/${taskId}`, { headers: { Authorization: apiKey }, params: { language: "en" } });
|
||||||
|
if (res.data.code !== 200) return { completed: false, error: `查询失败: ${JSON.stringify(res.data)}` };
|
||||||
|
const { status, result } = res.data.data;
|
||||||
|
if (status === "completed") return { completed: true, imageUrl: result?.images?.[0]?.url?.[0] };
|
||||||
|
if (status === "failed" || status === "cancelled") return { completed: false, error: `任务${status}` };
|
||||||
|
return { completed: false };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateImage = async (config: ImageConfig): Promise<string> => {
|
||||||
|
const { model, apiKey, baseURL, manufacturer } = await u.getConfig("image");
|
||||||
|
const generator = generators[manufacturer as keyof typeof generators];
|
||||||
|
if (!generator) throw new Error(`不支持的厂商: ${manufacturer}`);
|
||||||
|
|
||||||
|
let imageUrl = await generator(config, apiKey ?? "", baseURL ?? "", model ?? "");
|
||||||
|
if (!config.resType) config.resType = "b64";
|
||||||
|
if (config.resType === "b64" && imageUrl.startsWith("http")) imageUrl = await urlToBase64(imageUrl);
|
||||||
|
return imageUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VideoAspectRatio = "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9" | "adaptive";
|
||||||
|
interface BaseVideoConfig {
|
||||||
|
prompt: string;
|
||||||
|
savePath: string;
|
||||||
|
imageBase64?: string[]; // 单张参考图片 base64
|
||||||
|
}
|
||||||
|
interface DoubaoVideoConfig extends BaseVideoConfig {
|
||||||
|
duration: 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; // 支持 2~12 秒
|
||||||
|
aspectRatio: VideoAspectRatio;
|
||||||
|
audio?: boolean;
|
||||||
|
}
|
||||||
|
interface RunninghubVideoConfig extends BaseVideoConfig {
|
||||||
|
duration: 10 | 15; // 仅支持 10 或 15 秒
|
||||||
|
aspectRatio: "16:9" | "9:16" | "1:1"; // 仅支持这三种比例
|
||||||
|
}
|
||||||
|
interface OpenAIVideoConfig extends BaseVideoConfig {
|
||||||
|
duration: 10 | 15; // 仅支持 10 或 15 秒
|
||||||
|
aspectRatio: Exclude<VideoAspectRatio, "adaptive">; // 不支持 adaptive
|
||||||
|
}
|
||||||
|
type VideoConfig = DoubaoVideoConfig | RunninghubVideoConfig | OpenAIVideoConfig;
|
||||||
|
const generateVideoWithConfig = async (config: VideoConfig, configItem: { model: string; apiKey: string; baseURL: string; manufacturer: string }) => {
|
||||||
|
const { apiKey, baseURL, manufacturer, model } = configItem;
|
||||||
|
const imageArrPath = [];
|
||||||
|
for (const imageVal of config?.imageBase64!) {
|
||||||
|
// 判断是否为base64串
|
||||||
|
const isBase64 = typeof imageVal === "string" && /^data:image\/[a-zA-Z0-9\+\-\.]+;base64,[\s\S]+$/.test(imageVal.trim());
|
||||||
|
if (isBase64) {
|
||||||
|
imageArrPath.push(imageVal);
|
||||||
|
} else {
|
||||||
|
const base64 = await urlToBase64(imageVal);
|
||||||
|
imageArrPath.push(base64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.imageBase64 = imageArrPath;
|
||||||
|
let videoUrl: string | null = null;
|
||||||
|
if (manufacturer === "volcengine") {
|
||||||
|
const doubaoConfig = config as DoubaoVideoConfig;
|
||||||
|
const createRes = await axios.post(
|
||||||
|
baseURL ?? "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks",
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: config.prompt },
|
||||||
|
...(doubaoConfig.imageBase64
|
||||||
|
? doubaoConfig.imageBase64.map((base64, i) => ({
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: base64 },
|
||||||
|
role: i === 0 ? "first_frame" : "last_frame",
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
generate_audio: doubaoConfig.audio ?? false,
|
||||||
|
duration: doubaoConfig.duration,
|
||||||
|
resolution: doubaoConfig.aspectRatio,
|
||||||
|
watermark: false,
|
||||||
|
},
|
||||||
|
{ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` } },
|
||||||
|
);
|
||||||
|
const taskId = createRes.data.id;
|
||||||
|
if (!taskId) throw new Error("视频任务创建失败");
|
||||||
|
videoUrl = await pollTask(async () => {
|
||||||
|
const res = await axios.get(`${baseURL ?? "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks"}/${taskId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
const { status, content } = res.data;
|
||||||
|
if (status === "succeeded") return { completed: true, imageUrl: content?.video_url };
|
||||||
|
if (["failed", "cancelled", "expired"].includes(status)) return { completed: false, error: `任务${status}` };
|
||||||
|
if (["queued", "running"].includes(status)) return { completed: false };
|
||||||
|
return { completed: false, error: `未知状态: ${status}` };
|
||||||
|
});
|
||||||
|
} else if (manufacturer === "runninghub") {
|
||||||
|
const runninghubConfig = config as RunninghubVideoConfig;
|
||||||
|
// 如果有图片,先上传
|
||||||
|
let uploadedImageUrl: string | undefined;
|
||||||
|
if (runninghubConfig.imageBase64 && runninghubConfig.imageBase64.length > 0) {
|
||||||
|
uploadedImageUrl = await uploadBase64ToRunninghub(runninghubConfig.imageBase64[0]!, apiKey ?? "", "https://www.runninghub.cn");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = uploadedImageUrl ? "/openapi/v2/rhart-video-s/image-to-video" : "/openapi/v2/rhart-video-s/text-to-video";
|
||||||
|
const requestBody = uploadedImageUrl
|
||||||
|
? {
|
||||||
|
prompt: config.prompt,
|
||||||
|
imageUrl: uploadedImageUrl,
|
||||||
|
duration: String(runninghubConfig.duration) as "10" | "15",
|
||||||
|
aspectRatio: runninghubConfig.aspectRatio,
|
||||||
|
}
|
||||||
|
: { prompt: config.prompt, model };
|
||||||
|
const createRes = await axios.post(`https://www.runninghub.cn${endpoint}`, requestBody, {
|
||||||
|
headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { taskId, status: initialStatus, errorMessage } = createRes.data;
|
||||||
|
if (!taskId) throw new Error(`视频任务创建失败: ${errorMessage || "未知错误"}`);
|
||||||
|
if (initialStatus === "FAILED") throw new Error(`任务创建失败: ${errorMessage}`);
|
||||||
|
videoUrl = await pollTask(async () => {
|
||||||
|
const res = await axios.post(
|
||||||
|
`https://www.runninghub.cn/task/openapi/outputs`,
|
||||||
|
{ apiKey: apiKey?.replace("Bearer ", ""), taskId },
|
||||||
|
{ headers: { Authorization: "Bearer " + apiKey } },
|
||||||
|
);
|
||||||
|
const { code, msg, data } = res.data;
|
||||||
|
|
||||||
|
// 成功完成
|
||||||
|
if (code === 0 && msg === "success" && data?.[0]?.fileUrl) {
|
||||||
|
return { completed: true, imageUrl: data[0].fileUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进行中
|
||||||
|
if (code === 804 || code === 813) {
|
||||||
|
return { completed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败
|
||||||
|
if (code === 805) {
|
||||||
|
const failedReason = data?.[0]?.failedReason;
|
||||||
|
let errorMsg = "未知原因";
|
||||||
|
|
||||||
|
if (failedReason) {
|
||||||
|
// 尝试多种可能的错误信息字段
|
||||||
|
errorMsg =
|
||||||
|
failedReason.exception_message ||
|
||||||
|
failedReason.exceptionMessage ||
|
||||||
|
failedReason.message ||
|
||||||
|
failedReason.reason ||
|
||||||
|
JSON.stringify(failedReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed: false,
|
||||||
|
error: `任务失败: ${errorMsg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他未知状态
|
||||||
|
return {
|
||||||
|
completed: false,
|
||||||
|
error: `未知状态: code=${code}, msg=${msg}, data=${JSON.stringify(data)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (manufacturer === "openAi") {
|
||||||
|
const openaiConfig = config as OpenAIVideoConfig;
|
||||||
|
// 如果有图片,先上传
|
||||||
|
let uploadedImageUrl: string | undefined;
|
||||||
|
if (openaiConfig.imageBase64 && openaiConfig.imageBase64.length) {
|
||||||
|
const base64Data = openaiConfig.imageBase64[0]!.replace(/^data:image\/\w+;base64,/, "");
|
||||||
|
const buffer = Buffer.from(base64Data, "base64");
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", buffer, { filename: "image.jpg", contentType: "image/jpeg" });
|
||||||
|
const uploadRes = await axios.post(`${baseURL}/videos`, formData, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
...formData.getHeaders(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
uploadedImageUrl = uploadRes.data?.id || uploadRes.data?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建视频生成任务
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("model", model);
|
||||||
|
formData.append("prompt", config.prompt);
|
||||||
|
formData.append("seconds", String(openaiConfig.duration));
|
||||||
|
|
||||||
|
// 根据 aspectRatio 设置 size
|
||||||
|
const sizeMap: Record<string, string> = {
|
||||||
|
"16:9": "1920x1080",
|
||||||
|
"9:16": "1080x1920",
|
||||||
|
"1:1": "1080x1080",
|
||||||
|
"4:3": "1440x1080",
|
||||||
|
"3:4": "1080x1440",
|
||||||
|
"21:9": "2560x1080",
|
||||||
|
};
|
||||||
|
formData.append("size", sizeMap[openaiConfig.aspectRatio] || "1920x1080");
|
||||||
|
if (uploadedImageUrl) {
|
||||||
|
formData.append("input_reference", uploadedImageUrl);
|
||||||
|
}
|
||||||
|
const createRes = await axios.post(`${baseURL}/videos`, formData, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
...formData.getHeaders(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskId = createRes.data?.id;
|
||||||
|
|
||||||
|
if (!taskId) throw new Error("视频任务创建失败");
|
||||||
|
// 轮询任务状态
|
||||||
|
videoUrl = await pollTask(async () => {
|
||||||
|
const res = await axios.get(`${baseURL}/videos/${taskId}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { status, imageUrl, failReason } = res.data;
|
||||||
|
if (status === "SUCCESS") return { completed: true, imageUrl };
|
||||||
|
if (status === "FAILURE" || status === "CANCEL") {
|
||||||
|
return { completed: false, error: `任务${status}: ${failReason || "未知原因"}` };
|
||||||
|
}
|
||||||
|
if (["NOT_START", "SUBMITTED", "IN_PROGRESS", "MODAL"].includes(status)) {
|
||||||
|
return { completed: false };
|
||||||
|
}
|
||||||
|
return { completed: false, error: `未知状态: ${status}` };
|
||||||
|
});
|
||||||
|
} else if (manufacturer === "apimart") {
|
||||||
|
// apimart 视频生成
|
||||||
|
const apimartConfig = config as OpenAIVideoConfig;
|
||||||
|
const apimartBaseURL = "https://api.apimart.ai";
|
||||||
|
|
||||||
|
// 上传图片到 apimart 图床
|
||||||
|
let imageUrls: string[] = [];
|
||||||
|
if (apimartConfig.imageBase64 && apimartConfig.imageBase64.length > 0) {
|
||||||
|
for (const base64Image of apimartConfig.imageBase64) {
|
||||||
|
// 如果已经是 URL,直接使用
|
||||||
|
if (base64Image.startsWith("http")) {
|
||||||
|
imageUrls.push(base64Image);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取预签名 URL
|
||||||
|
const presignRes = await axios.post(
|
||||||
|
"https://apimart.ai/api/upload/presign",
|
||||||
|
{ contentType: "image/jpeg", fileExtension: "jpeg", permanent: false },
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!presignRes.data.success || !presignRes.data.presignedUrl || !presignRes.data.cdnUrl) {
|
||||||
|
throw new Error(`获取预签名 URL 失败: ${JSON.stringify(presignRes.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { presignedUrl, cdnUrl } = presignRes.data;
|
||||||
|
|
||||||
|
// 移除 base64 前缀并转为 buffer
|
||||||
|
const base64Data = base64Image.replace(/^data:image\/\w+;base64,/, "");
|
||||||
|
const buffer = Buffer.from(base64Data, "base64");
|
||||||
|
|
||||||
|
// 上传图片到预签名 URL
|
||||||
|
await axios.put(presignedUrl, buffer, {
|
||||||
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
|
});
|
||||||
|
|
||||||
|
imageUrls.push(cdnUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建视频生成任务
|
||||||
|
const requestBody: {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
duration: number;
|
||||||
|
aspect_ratio: string;
|
||||||
|
image_urls?: string[];
|
||||||
|
} = {
|
||||||
|
model: model || "sora-2",
|
||||||
|
prompt: config.prompt,
|
||||||
|
duration: apimartConfig.duration,
|
||||||
|
aspect_ratio: apimartConfig.aspectRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageUrls.length > 0) {
|
||||||
|
requestBody.image_urls = imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRes = await axios.post(`${apimartBaseURL}/v1/videos/generations`, requestBody, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createRes.data.code !== 200 || !createRes.data.data?.[0]?.task_id) {
|
||||||
|
const errorMsg = createRes.data.error?.message || JSON.stringify(createRes.data);
|
||||||
|
throw new Error(`视频任务创建失败: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = createRes.data.data[0].task_id;
|
||||||
|
|
||||||
|
// 轮询任务状态
|
||||||
|
videoUrl = await pollTask(async () => {
|
||||||
|
const res = await axios.get(`${apimartBaseURL}/v1/tasks/${taskId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
params: { language: "en" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否有错误
|
||||||
|
if (res.data.error) {
|
||||||
|
return {
|
||||||
|
completed: false,
|
||||||
|
error: `查询失败: ${res.data.error.message || JSON.stringify(res.data.error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.code !== 200) {
|
||||||
|
return { completed: false, error: `查询失败: ${JSON.stringify(res.data)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, result } = res.data.data;
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
// 获取视频 URL
|
||||||
|
const videoUrlResult = result?.videos?.[0]?.url?.[0];
|
||||||
|
return { completed: true, imageUrl: videoUrlResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "failed" || status === "cancelled") {
|
||||||
|
return { completed: false, error: `任务${status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他状态(submitted, processing 等)继续轮询
|
||||||
|
return { completed: false };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(`不支持的厂商: ${manufacturer}`);
|
||||||
|
}
|
||||||
|
return videoUrl;
|
||||||
|
};
|
||||||
|
export const generateVideo = async (config: VideoConfig, manufacturer: string) => {
|
||||||
|
if (!config.imageBase64 || config.imageBase64.length <= 0) throw new Error("未传图片");
|
||||||
|
const configList = await u.getConfig("video", manufacturer);
|
||||||
|
if (!configList || configList.length === 0) {
|
||||||
|
throw new Error("未找到任何视频配置");
|
||||||
|
}
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (const configItem of configList) {
|
||||||
|
// 每个配置项重试1次,共2次尝试
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const videoUrl = await generateVideoWithConfig(config, configItem);
|
||||||
|
if (videoUrl) {
|
||||||
|
const response = await axios.get(videoUrl, { responseType: "stream" });
|
||||||
|
await u.oss.writeFile(config.savePath, response.data);
|
||||||
|
return config.savePath;
|
||||||
|
}
|
||||||
|
return videoUrl;
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error as Error;
|
||||||
|
console.warn(`配置 ${configItem.model} 第 ${attempt + 1} 次尝试失败:`, error?.response?.data || error.message);
|
||||||
|
// 如果是第一次尝试失败,继续重试
|
||||||
|
if (attempt === 0) continue;
|
||||||
|
// 第二次也失败了,跳到下一个配置项
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 所有配置都失败了
|
||||||
|
throw new Error(`所有视频配置都失败了。最后一次错误: ${lastError?.message || "未知错误"}`);
|
||||||
|
};
|
||||||
95
src/utils/db.ts
Normal file
95
src/utils/db.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { readFile, writeFile } from "fs/promises";
|
||||||
|
import u from "@/utils";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import knex from "knex";
|
||||||
|
import initDB from "@/lib/initDB";
|
||||||
|
import type { DB } from "@/types/database";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
type TableName = keyof DB & string;
|
||||||
|
type RowType<TName extends TableName> = DB[TName];
|
||||||
|
|
||||||
|
let dbPath: string;
|
||||||
|
if (typeof process.versions?.electron !== "undefined") {
|
||||||
|
const { app } = require("electron");
|
||||||
|
const userDataDir: string = app.getPath("userData");
|
||||||
|
dbPath = path.join(userDataDir, "db.sqlite");
|
||||||
|
} else {
|
||||||
|
dbPath = path.join(process.cwd(), "db.sqlite");
|
||||||
|
}
|
||||||
|
console.log("数据库目录:", dbPath);
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
// 确保数据库目录存在
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建空数据库文件
|
||||||
|
if (!fs.existsSync(dbPath)) {
|
||||||
|
fs.writeFileSync(dbPath, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = knex({
|
||||||
|
client: "sqlite3",
|
||||||
|
connection: {
|
||||||
|
filename: dbPath,
|
||||||
|
},
|
||||||
|
useNullAsDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
initDB(db);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV == "dev") initKnexType(db);
|
||||||
|
|
||||||
|
const dbClient = Object.assign(<TName extends TableName>(table: TName) => db<RowType<TName>, RowType<TName>[]>(table), db);
|
||||||
|
dbClient.schema = db.schema;
|
||||||
|
export default dbClient;
|
||||||
|
|
||||||
|
export { db };
|
||||||
|
|
||||||
|
async function initKnexType(knexDb: any) {
|
||||||
|
const { Client } = await import("@rmp135/sql-ts");
|
||||||
|
const outFile = "src/types/database.d.ts";
|
||||||
|
const dbClient = Client.fromConfig({
|
||||||
|
interfaceNameFormat: "${table}",
|
||||||
|
typeMap: {
|
||||||
|
number: ["bigint"],
|
||||||
|
string: ["text", "varchar", "char"],
|
||||||
|
},
|
||||||
|
}).fetchDatabase(knexDb);
|
||||||
|
const declarations = await dbClient.toTypescript();
|
||||||
|
const dbObject = await dbClient.toObject();
|
||||||
|
const customHeader = `//该文件由脚本自动生成,请勿手动修改`;
|
||||||
|
// 清除上次的注释头
|
||||||
|
let declBody = declarations.replace(/^\/\*[\s\S]*?\*\/\s*/, "");
|
||||||
|
declBody = declBody.replace(/(\n\s*)\/\*([^*][\s\S]*?)\*\//g, "$1/**$2*/");
|
||||||
|
const tableInterfaces = dbObject.schemas.flatMap((schema) => schema.tables.map((table) => table.interfaceName));
|
||||||
|
const aggregateTypes = `
|
||||||
|
export interface DB {
|
||||||
|
${tableInterfaces.map((name) => ` ${JSON.stringify(name)}: ${name};`).join("\n")}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
// 哈希仅基于结构化信息,header和空格不算
|
||||||
|
const hashSource = JSON.stringify({
|
||||||
|
tableInterfaces,
|
||||||
|
declBody,
|
||||||
|
});
|
||||||
|
const hash = crypto.createHash("md5").update(hashSource).digest("hex");
|
||||||
|
// 文件内容
|
||||||
|
const content = `// @db-hash ${hash}\n${customHeader}\n\n` + declBody + aggregateTypes;
|
||||||
|
let needWrite = true;
|
||||||
|
try {
|
||||||
|
const current = await readFile(outFile, "utf8");
|
||||||
|
// 文件头已存在相同 hash,不需要写
|
||||||
|
const match = current.match(/^\/\/\s*@db-hash\s*([a-zA-Z0-9]+)\n/);
|
||||||
|
const currentHash = match ? match[1] : null;
|
||||||
|
if (currentHash === hash) {
|
||||||
|
needWrite = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
needWrite = true;
|
||||||
|
}
|
||||||
|
if (needWrite) await writeFile(outFile, content, "utf8");
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user