项目初始化
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/.idea/*
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# 阶段1: 构建
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV GO111MODULE=on
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOTOOLCHAIN=auto
|
||||
WORKDIR /build
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go mod download && go mod tidy
|
||||
RUN go build -ldflags="-s -w" -o main ./main.go
|
||||
|
||||
# 阶段2: 运行
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/main .
|
||||
COPY --from=builder /build/config.yml .
|
||||
|
||||
EXPOSE 3006
|
||||
|
||||
VOLUME ["/app/output"]
|
||||
|
||||
CMD ["./main"]
|
||||
@@ -0,0 +1,57 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
var Httpserver = g.Server()
|
||||
|
||||
func init() {
|
||||
err := gtime.SetTimeZone("Asia/Shanghai")
|
||||
if err != nil {
|
||||
panic("设置时区失败")
|
||||
}
|
||||
Httpserver.SetOpenApiPath("/api.json")
|
||||
Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse)
|
||||
}
|
||||
|
||||
// RouteRegister 根据控制器结构体名称自动注册路由
|
||||
func RouteRegister(controllers []interface{}) {
|
||||
re := regexp.MustCompile("[A-Z]")
|
||||
for _, t := range controllers {
|
||||
sName := reflect.ValueOf(t).Elem().Type().Name()
|
||||
convertedStr := re.ReplaceAllStringFunc(sName, func(s string) string {
|
||||
return fmt.Sprintf("/%s", strings.ToLower(s))
|
||||
})
|
||||
if len(convertedStr) > 0 && convertedStr[0] == '/' {
|
||||
convertedStr = convertedStr[1:]
|
||||
}
|
||||
Httpserver.Group("/"+convertedStr, func(group *ghttp.RouterGroup) {
|
||||
group.Bind(t)
|
||||
})
|
||||
}
|
||||
go Httpserver.Run()
|
||||
}
|
||||
|
||||
// RouteRegisterRaw 注册原始路径路由(支持 :param 路径参数)
|
||||
func RouteRegisterRaw(method, pattern string, handler ghttp.HandlerFunc) {
|
||||
switch method {
|
||||
case "GET":
|
||||
Httpserver.BindHandler(pattern, handler)
|
||||
case "POST":
|
||||
Httpserver.BindHandler(pattern, handler)
|
||||
case "PUT":
|
||||
Httpserver.BindHandler(pattern, handler)
|
||||
case "DELETE":
|
||||
Httpserver.BindHandler(pattern, handler)
|
||||
default:
|
||||
Httpserver.BindHandler(pattern, handler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
database:
|
||||
default:
|
||||
name: short_drama.db
|
||||
type: sqlite
|
||||
server:
|
||||
address: :3006
|
||||
name: video
|
||||
workerId: 1
|
||||
@@ -0,0 +1,73 @@
|
||||
module video-factory
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/cloudwego/eino v0.9.5
|
||||
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
|
||||
github.com/gogf/gf/v2 v2.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eino-contrib/jsonschema v1.0.3 // indirect
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/goph/emperror v0.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/arch v0.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,228 @@
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
|
||||
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
|
||||
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.9.5 h1:0Nftjx9gPek/2S/hzm38LVxSjk5/6mqRr3I9VKrKvm4=
|
||||
github.com/cloudwego/eino v0.9.5/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
|
||||
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9 h1:xCz/mp43JeWqupjPR3zLRArmwC6P29/6lTwbwh1yzYM=
|
||||
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9/go.mod h1:slTGTuhzkzhNavf+1UtUg1FvUSA31iNAF+rq1mT4SnI=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
|
||||
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g=
|
||||
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
||||
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
|
||||
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
|
||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
||||
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
||||
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
||||
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"net/http"
|
||||
|
||||
commonHttp "video-factory/common/http"
|
||||
"video-factory/shortdrama/controller"
|
||||
"video-factory/shortdrama/service"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
//go:embed shortdrama/view/*.html
|
||||
var viewFS embed.FS
|
||||
|
||||
func main() {
|
||||
// ==================== HTML 页面路由 ====================
|
||||
servePage := func(name string) ghttp.HandlerFunc {
|
||||
return func(r *ghttp.Request) {
|
||||
data, err := viewFS.ReadFile("shortdrama/view/" + name)
|
||||
if err != nil {
|
||||
r.Response.WriteStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
r.Response.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
commonHttp.Httpserver.BindHandler("/", servePage("index.html"))
|
||||
|
||||
// ==================== API 路由(通过 RouteRegister 自动注册,遵循 ai-agent 规范) ====================
|
||||
commonHttp.RouteRegister([]interface{}{
|
||||
controller.Drama,
|
||||
controller.Config,
|
||||
controller.Prompt,
|
||||
})
|
||||
|
||||
// 为合并后的视频提供静态文件服务
|
||||
commonHttp.Httpserver.AddStaticPath("/output", "output")
|
||||
|
||||
// 数据库迁移
|
||||
service.DramaService.Migrate(context.Background())
|
||||
|
||||
// 初始化提示词表
|
||||
_ = service.PromptService.Init(context.Background())
|
||||
|
||||
// 启动时恢复未完成的视频生成轮询
|
||||
service.DramaService.StartVideoPoller(context.Background())
|
||||
|
||||
// 保持运行
|
||||
select {}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,99 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/qwen"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ModelConfig 模型配置 — 所有字段必须显式提供,无硬编码默认值
|
||||
type ModelConfig struct {
|
||||
ModelName string // 对话模型名
|
||||
APIKey string // API密钥
|
||||
BaseURL string // API地址
|
||||
MaxTokens int // 最大Token数
|
||||
Temperature float32 // 温度参数
|
||||
ImageModel string // 图片模型名
|
||||
}
|
||||
|
||||
// context keys
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
ctxKeyAPIKey ctxKey = "api_key"
|
||||
ctxKeyImageModel ctxKey = "image_model"
|
||||
ctxKeyBaseURL ctxKey = "base_url"
|
||||
)
|
||||
|
||||
// WithModelConfig 将模型配置注入 context,供工具函数读取
|
||||
func WithModelConfig(ctx context.Context, cfg *ModelConfig) context.Context {
|
||||
ctx = context.WithValue(ctx, ctxKeyAPIKey, cfg.APIKey)
|
||||
ctx = context.WithValue(ctx, ctxKeyImageModel, cfg.ImageModel)
|
||||
ctx = context.WithValue(ctx, ctxKeyBaseURL, cfg.BaseURL)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// GetAPIKey 从 context 获取 API key
|
||||
func GetAPIKey(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyAPIKey).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetImageModel 从 context 获取图片模型名
|
||||
func GetImageModel(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyImageModel).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetBaseURL 从 context 获取 API 地址
|
||||
func GetBaseURL(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyBaseURL).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewChatModel 根据配置初始化聊天模型
|
||||
func NewChatModel(ctx context.Context, cfg *ModelConfig) (cm model.ChatModel, err error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("模型配置不能为空")
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("APIKey 未配置")
|
||||
}
|
||||
if cfg.ModelName == "" {
|
||||
return nil, fmt.Errorf("模型名称未配置")
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
return nil, fmt.Errorf("API 地址未配置")
|
||||
}
|
||||
|
||||
maxTokens := cfg.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
temperature := cfg.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = 0.8
|
||||
}
|
||||
|
||||
config := &qwen.ChatModelConfig{
|
||||
APIKey: cfg.APIKey,
|
||||
Model: cfg.ModelName,
|
||||
BaseURL: cfg.BaseURL,
|
||||
MaxTokens: gconv.PtrInt(maxTokens),
|
||||
Temperature: gconv.PtrFloat32(temperature),
|
||||
}
|
||||
cm, err = qwen.NewChatModel(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建模型失败: %w", err)
|
||||
}
|
||||
return cm, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ReActAgent 实现 ReAct 模式的智能体
|
||||
type ReActAgent struct {
|
||||
model model.ChatModel
|
||||
tools []*ToolInfo
|
||||
systemPrompt string
|
||||
maxStep int
|
||||
}
|
||||
|
||||
// NewReActAgent 创建 ReAct 智能体
|
||||
func NewReActAgent(ctx context.Context, chatModel model.ChatModel, tools []*ToolInfo, systemPrompt string, maxStep int) *ReActAgent {
|
||||
return &ReActAgent{
|
||||
model: chatModel,
|
||||
tools: tools,
|
||||
systemPrompt: systemPrompt,
|
||||
maxStep: maxStep,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行 ReAct 循环
|
||||
// 标准流程: 思考 → 行动(调用工具) → 观察(工具结果) → 重复 → 最终回答
|
||||
func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) {
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(a.systemPrompt),
|
||||
schema.UserMessage(userInput),
|
||||
}
|
||||
|
||||
// 构建 toolInfos 传给模型
|
||||
toolInfos := make([]*schema.ToolInfo, 0, len(a.tools))
|
||||
for _, t := range a.tools {
|
||||
toolInfos = append(toolInfos, t.ToEinoToolInfo())
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "ReAct 开始执行,maxStep=%d, tools=%d", a.maxStep, len(a.tools))
|
||||
|
||||
for step := 0; step < a.maxStep; step++ {
|
||||
g.Log().Infof(ctx, "ReAct step %d/%d: 调用模型...", step+1, a.maxStep)
|
||||
|
||||
// 1. LLM 思考并决定行动
|
||||
startTime := time.Now()
|
||||
result, err := a.model.Generate(ctx, messages, model.WithTools(toolInfos))
|
||||
elapsed := time.Since(startTime)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("step %d: 模型调用失败: %w", step, err)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "ReAct step %d/%d: 模型返回 (耗时 %v), content_len=%d, ToolCalls=%d",
|
||||
step+1, a.maxStep, elapsed, len(result.Content), len(result.ToolCalls))
|
||||
|
||||
messages = append(messages, result)
|
||||
|
||||
// 2. 检查是否有工具调用
|
||||
if len(result.ToolCalls) == 0 {
|
||||
// 没有工具调用 → 最终回答
|
||||
g.Log().Infof(ctx, "ReAct step %d/%d: 无工具调用,返回最终结果 (content长度=%d)", step+1, a.maxStep, len(result.Content))
|
||||
return result.Content, nil
|
||||
}
|
||||
|
||||
// 3. 执行每个工具调用
|
||||
for _, tc := range result.ToolCalls {
|
||||
tool := a.findTool(tc.Function.Name)
|
||||
if tool == nil {
|
||||
g.Log().Warningf(ctx, "ReAct step %d: 未知工具: %s", step+1, tc.Function.Name)
|
||||
toolResultMsg := &schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: fmt.Sprintf("未知工具: %s", tc.Function.Name),
|
||||
ToolName: tc.Function.Name,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, toolResultMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
g.Log().Warningf(ctx, "ReAct step %d: 参数解析失败: %v", step+1, err)
|
||||
toolResultMsg := &schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: fmt.Sprintf("参数解析失败: %v", err),
|
||||
ToolName: tc.Function.Name,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, toolResultMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "ReAct step %d: 调用工具 %s, 参数: %s", step+1, tc.Function.Name, tc.Function.Arguments)
|
||||
|
||||
// 执行工具
|
||||
toolStart := time.Now()
|
||||
output, err := tool.Func(ctx, args)
|
||||
toolElapsed := time.Since(toolStart)
|
||||
if err != nil {
|
||||
output = fmt.Sprintf("工具执行失败: %v", err)
|
||||
g.Log().Warningf(ctx, "ReAct step %d: 工具 %s 执行失败 (耗时 %v): %v", step+1, tc.Function.Name, toolElapsed, err)
|
||||
} else {
|
||||
truncated := output
|
||||
if len(truncated) > 200 {
|
||||
truncated = truncated[:200] + "..."
|
||||
}
|
||||
g.Log().Infof(ctx, "ReAct step %d: 工具 %s 执行成功 (耗时 %v), 结果长度=%d, 预览: %s", step+1, tc.Function.Name, toolElapsed, len(output), truncated)
|
||||
}
|
||||
|
||||
// 4. 观察工具结果
|
||||
toolResultMsg := &schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: output,
|
||||
Name: tc.Function.Name,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, toolResultMsg)
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Errorf(ctx, "ReAct 达到最大步骤数 %d,生成未完成", a.maxStep)
|
||||
return "", fmt.Errorf("达到最大步骤数 %d,生成未完成", a.maxStep)
|
||||
}
|
||||
|
||||
func (a *ReActAgent) findTool(name string) *ToolInfo {
|
||||
for _, t := range a.tools {
|
||||
if t.Name == name {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ToolInfo 工具定义
|
||||
type ToolInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters map[string]any
|
||||
Func func(ctx context.Context, args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
// ToEinoToolInfo 转换为 Eino 的 ToolInfo 格式
|
||||
func (t *ToolInfo) ToEinoToolInfo() *schema.ToolInfo {
|
||||
params := make(map[string]*schema.ParameterInfo)
|
||||
if paramsMap, ok := t.Parameters["properties"].(map[string]any); ok {
|
||||
for key, val := range paramsMap {
|
||||
if prop, ok := val.(map[string]any); ok {
|
||||
desc, _ := prop["description"].(string)
|
||||
pType, _ := prop["type"].(string)
|
||||
params[key] = &schema.ParameterInfo{
|
||||
Type: schema.DataType(pType),
|
||||
Desc: desc,
|
||||
Required: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if required, ok := t.Parameters["required"].([]any); ok {
|
||||
for _, r := range required {
|
||||
if rStr, ok := r.(string); ok {
|
||||
if p, exists := params[rStr]; exists {
|
||||
p.Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &schema.ToolInfo{
|
||||
Name: t.Name,
|
||||
Desc: t.Description,
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(params),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTools 获取 ReAct Agent 可用的所有工具
|
||||
func GetTools() []*ToolInfo {
|
||||
return []*ToolInfo{
|
||||
parseScriptTool(),
|
||||
analyzeScriptForEpisodeTool(),
|
||||
generateCharacterImageTool(),
|
||||
generateSceneImageTool(),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tool 1: 解析剧本 ====================
|
||||
|
||||
func parseScriptTool() *ToolInfo {
|
||||
return &ToolInfo{
|
||||
Name: "parse_script",
|
||||
Description: "将原始剧本文本解析为结构化的剧集列表,支持用 --- 分隔的多集剧本",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"raw_script": map[string]any{
|
||||
"type": "string",
|
||||
"description": "原始剧本文本,多集用 --- 分隔",
|
||||
},
|
||||
},
|
||||
"required": []string{"raw_script"},
|
||||
},
|
||||
Func: func(ctx context.Context, args map[string]any) (string, error) {
|
||||
rawScript, _ := args["raw_script"].(string)
|
||||
if rawScript == "" {
|
||||
return "", fmt.Errorf("剧本内容不能为空")
|
||||
}
|
||||
|
||||
// 按 --- 分割多集
|
||||
episodeTexts := strings.Split(rawScript, "---")
|
||||
type episodeInfo struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Script string `json:"script"`
|
||||
}
|
||||
var episodes []episodeInfo
|
||||
|
||||
for i, text := range episodeTexts {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
lines := strings.SplitN(text, "\n", 2)
|
||||
title := strings.TrimSpace(lines[0])
|
||||
// 去掉可能的序号前缀如 "第1集"、"第一集"、"Episode 1" 等
|
||||
title = cleanEpisodeTitle(title)
|
||||
content := ""
|
||||
if len(lines) > 1 {
|
||||
content = strings.TrimSpace(lines[1])
|
||||
} else {
|
||||
content = title
|
||||
}
|
||||
episodes = append(episodes, episodeInfo{
|
||||
Index: i + 1,
|
||||
Title: title,
|
||||
Script: content,
|
||||
})
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"episodes": episodes,
|
||||
"total_episodes": len(episodes),
|
||||
})
|
||||
return string(result), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tool 2: 分析单集剧本 ====================
|
||||
|
||||
func analyzeScriptForEpisodeTool() *ToolInfo {
|
||||
return &ToolInfo{
|
||||
Name: "analyze_script_for_episode",
|
||||
Description: "分析单集剧本内容,根据时长将剧本拆分为多个场景,识别出场演员及画面描述",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"episode_index": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "剧集索引(从1开始)",
|
||||
},
|
||||
"episode_title": map[string]any{
|
||||
"type": "string",
|
||||
"description": "本集标题",
|
||||
},
|
||||
"script_content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "本集剧本内容",
|
||||
},
|
||||
"duration": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "本集总时长(秒)",
|
||||
},
|
||||
"characters": map[string]any{
|
||||
"type": "string",
|
||||
"description": "演员列表JSON,格式:[{\"name\":\"演员名\",\"description\":\"演员描述\"}]",
|
||||
},
|
||||
},
|
||||
"required": []string{"episode_index", "script_content", "duration", "characters"},
|
||||
},
|
||||
Func: func(ctx context.Context, args map[string]any) (string, error) {
|
||||
scriptContent, _ := args["script_content"].(string)
|
||||
durationFloat, _ := args["duration"].(float64)
|
||||
duration := int(durationFloat)
|
||||
|
||||
if scriptContent == "" {
|
||||
return "", fmt.Errorf("剧本内容不能为空")
|
||||
}
|
||||
if duration <= 0 {
|
||||
duration = 60 // 默认60秒
|
||||
}
|
||||
|
||||
episodeIndex, _ := args["episode_index"].(float64)
|
||||
title, _ := args["episode_title"].(string)
|
||||
|
||||
// 按空行或场景标记分割场景
|
||||
sceneTexts := strings.Split(scriptContent, "\n\n")
|
||||
type sceneInfo struct {
|
||||
Index int `json:"index"`
|
||||
Description string `json:"description"`
|
||||
Lines string `json:"lines"`
|
||||
Duration int `json:"duration"`
|
||||
Characters []string `json:"characters"`
|
||||
VisualDesc string `json:"visualDesc"`
|
||||
}
|
||||
var scenes []sceneInfo
|
||||
|
||||
totalScenes := len(sceneTexts)
|
||||
if totalScenes == 0 {
|
||||
totalScenes = 1
|
||||
sceneTexts = []string{scriptContent}
|
||||
}
|
||||
|
||||
// 推测出场演员
|
||||
var characters []string
|
||||
if charsRaw, ok := args["characters"].(string); ok && charsRaw != "" {
|
||||
var chars []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.Unmarshal([]byte(charsRaw), &chars)
|
||||
for _, c := range chars {
|
||||
characters = append(characters, c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
perSceneDuration := duration / totalScenes
|
||||
remainder := duration % totalScenes
|
||||
|
||||
for i, text := range sceneTexts {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sceneDur := perSceneDuration
|
||||
if i < remainder {
|
||||
sceneDur++
|
||||
}
|
||||
|
||||
// 提取第一行作为场景描述
|
||||
lines := strings.SplitN(text, "\n", 2)
|
||||
desc := strings.TrimSpace(lines[0])
|
||||
content := ""
|
||||
if len(lines) > 1 {
|
||||
content = strings.TrimSpace(lines[1])
|
||||
} else {
|
||||
content = desc
|
||||
}
|
||||
|
||||
// 匹配出场演员
|
||||
var sceneChars []string
|
||||
for _, c := range characters {
|
||||
if strings.Contains(text, c) {
|
||||
sceneChars = append(sceneChars, c)
|
||||
}
|
||||
}
|
||||
|
||||
visualDesc := fmt.Sprintf("场景%d:%s,画面风格根据剧本内容自动生成", i+1, desc)
|
||||
|
||||
scenes = append(scenes, sceneInfo{
|
||||
Index: i + 1,
|
||||
Description: desc,
|
||||
Lines: content,
|
||||
Duration: sceneDur,
|
||||
Characters: sceneChars,
|
||||
VisualDesc: visualDesc,
|
||||
})
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"episode_index": int(episodeIndex),
|
||||
"episode_title": title,
|
||||
"total_scenes": len(scenes),
|
||||
"total_duration": duration,
|
||||
"scenes": scenes,
|
||||
})
|
||||
return string(result), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tool 3: 生成人物形象图 ====================
|
||||
|
||||
func generateCharacterImageTool() *ToolInfo {
|
||||
return &ToolInfo{
|
||||
Name: "generate_character_image",
|
||||
Description: "根据演员描述生成人物形象图,返回图片的base64编码数据",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"character_name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "演员名称",
|
||||
},
|
||||
"character_description": map[string]any{
|
||||
"type": "string",
|
||||
"description": "演员详细描述(外貌、服装、气质等)",
|
||||
},
|
||||
"style": map[string]any{
|
||||
"type": "string",
|
||||
"description": "整体风格(如:古装、现代、科幻)",
|
||||
},
|
||||
},
|
||||
"required": []string{"character_name", "character_description", "style"},
|
||||
},
|
||||
Func: func(ctx context.Context, args map[string]any) (string, error) {
|
||||
name, _ := args["character_name"].(string)
|
||||
desc, _ := args["character_description"].(string)
|
||||
style, _ := args["style"].(string)
|
||||
|
||||
prompt := fmt.Sprintf("演员:%s,描述:%s,风格:%s", name, desc, style)
|
||||
imgBase64, err := generateRealImage(ctx, prompt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成演员图片失败: %w", err)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"character_name": name,
|
||||
"image_base64": imgBase64,
|
||||
"status": "success",
|
||||
})
|
||||
return string(result), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tool 4: 生成场景图 ====================
|
||||
|
||||
func generateSceneImageTool() *ToolInfo {
|
||||
return &ToolInfo{
|
||||
Name: "generate_scene_image",
|
||||
Description: "根据场景的画面描述生成场景图片,返回图片的base64编码数据",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"episode_index": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "剧集索引",
|
||||
},
|
||||
"scene_index": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "场景索引",
|
||||
},
|
||||
"visual_description": map[string]any{
|
||||
"type": "string",
|
||||
"description": "画面描述(场景设定、演员动作、镜头角度等)",
|
||||
},
|
||||
"style": map[string]any{
|
||||
"type": "string",
|
||||
"description": "整体风格",
|
||||
},
|
||||
},
|
||||
"required": []string{"visual_description", "style"},
|
||||
},
|
||||
Func: func(ctx context.Context, args map[string]any) (string, error) {
|
||||
visualDesc, _ := args["visual_description"].(string)
|
||||
style, _ := args["style"].(string)
|
||||
episodeIdx, _ := args["episode_index"].(float64)
|
||||
sceneIdx, _ := args["scene_index"].(float64)
|
||||
|
||||
prompt := fmt.Sprintf("画面描述:%s,风格:%s", visualDesc, style)
|
||||
imgBase64, err := generateRealImage(ctx, prompt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成场景图片失败: %w", err)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"episode_index": int(episodeIdx),
|
||||
"scene_index": int(sceneIdx),
|
||||
"image_base64": imgBase64,
|
||||
"status": "success",
|
||||
})
|
||||
return string(result), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
func cleanEpisodeTitle(title string) string {
|
||||
prefixes := []string{"第", "Episode", "episode", "EP"}
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(title, p) {
|
||||
// 去掉序号前缀后取标题部分
|
||||
parts := strings.SplitN(title, " ", 2)
|
||||
if len(parts) > 1 {
|
||||
return parts[1]
|
||||
}
|
||||
parts = strings.SplitN(title, ":", 2)
|
||||
if len(parts) > 1 {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func generateRealImage(ctx context.Context, prompt string) (string, error) {
|
||||
imageModel := GetImageModel(ctx)
|
||||
if imageModel == "" {
|
||||
// 图片模型未配置,返回空字符串(不报错),避免 agent 反复重试
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 调用通义万相生成图片
|
||||
apiKey := GetAPIKey(ctx)
|
||||
url := "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
|
||||
body := map[string]any{
|
||||
"model": imageModel,
|
||||
"input": map[string]any{
|
||||
"messages": []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]string{
|
||||
{"type": "text", "text": prompt},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"parameters": map[string]any{
|
||||
"size": "1024*1364",
|
||||
"n": 1,
|
||||
"watermark": false,
|
||||
},
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Output struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Image string `json:"image"`
|
||||
} `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
} `json:"output"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &result)
|
||||
if err != nil || len(result.Output.Choices) == 0 || result.Code != "" {
|
||||
return "", fmt.Errorf("生成图片失败: %s", string(data))
|
||||
}
|
||||
|
||||
imgBase64 := result.Output.Choices[0].Message.Content[0].Image
|
||||
if imgBase64 == "" {
|
||||
return "", fmt.Errorf("图片内容为空")
|
||||
}
|
||||
|
||||
return imgBase64, nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package public
|
||||
|
||||
const (
|
||||
TableNameDrama = "short_drama"
|
||||
TableNameCharacter = "short_drama_character"
|
||||
TableNameEpisode = "short_drama_episode"
|
||||
TableNameGenerationTask = "short_drama_generation_task"
|
||||
TableNameModelConfig = "short_drama_model_config"
|
||||
TableNamePrompt = "short_drama_prompt"
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package consts
|
||||
|
||||
const (
|
||||
EpisodeStatusPending = "pending"
|
||||
EpisodeStatusGenerating = "generating"
|
||||
EpisodeStatusReview = "review"
|
||||
EpisodeStatusCompleted = "completed"
|
||||
EpisodeStatusFailed = "failed"
|
||||
|
||||
TaskStatusGenerating = "generating"
|
||||
TaskStatusReview = "review"
|
||||
TaskStatusCompleted = "completed"
|
||||
TaskStatusFailed = "failed"
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"video-factory/shortdrama/model/dto"
|
||||
"video-factory/shortdrama/service"
|
||||
)
|
||||
|
||||
type config struct{}
|
||||
|
||||
var Config = new(config)
|
||||
|
||||
func (c *config) Get(ctx context.Context, req *dto.GetModelConfigReq) (res *dto.GetModelConfigRes, err error) {
|
||||
cfg := service.ConfigService.Get(ctx)
|
||||
return &dto.GetModelConfigRes{ModelConfig: cfg}, nil
|
||||
}
|
||||
|
||||
func (c *config) Save(ctx context.Context, req *dto.SaveModelConfigReq) (res *struct{}, err error) {
|
||||
return nil, service.ConfigService.Save(ctx, &req.ModelConfig)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"video-factory/shortdrama/model/dto"
|
||||
"video-factory/shortdrama/service"
|
||||
)
|
||||
|
||||
type drama struct{}
|
||||
|
||||
var Drama = new(drama)
|
||||
|
||||
// ==================== Drama CRUD ====================
|
||||
|
||||
func (c *drama) List(ctx context.Context, req *dto.ListDramaReq) (res *dto.ListDramaRes, err error) {
|
||||
list, err := service.DramaService.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ListDramaRes{List: list}, nil
|
||||
}
|
||||
|
||||
func (c *drama) Create(ctx context.Context, req *dto.CreateDramaReq) (res *dto.CreateDramaRes, err error) {
|
||||
id, err := service.DramaService.Create(ctx, req.Title, req.Style, int64(req.EpisodeDuration))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CreateDramaRes{Id: id}, nil
|
||||
}
|
||||
|
||||
func (c *drama) Get(ctx context.Context, req *dto.GetDramaReq) (res *dto.GetDramaRes, err error) {
|
||||
drama, characters, episodes, tasks, err := service.DramaService.Get(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.GetDramaRes{
|
||||
Drama: drama,
|
||||
Characters: characters,
|
||||
Episodes: episodes,
|
||||
Tasks: tasks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *drama) Update(ctx context.Context, req *dto.UpdateDramaReq) (res *struct{}, err error) {
|
||||
return nil, service.DramaService.Update(ctx, req.Id, req.Title, req.Style, int64(req.EpisodeDuration))
|
||||
}
|
||||
|
||||
func (c *drama) Delete(ctx context.Context, req *dto.DeleteDramaReq) (res *struct{}, err error) {
|
||||
return nil, service.DramaService.Delete(ctx, req.Id)
|
||||
}
|
||||
|
||||
// ==================== Character CRUD ====================
|
||||
|
||||
func (c *drama) AddCharacter(ctx context.Context, req *dto.AddCharacterReq) (res *dto.GetDramaRes, err error) {
|
||||
_, err = service.DramaService.AddCharacter(ctx, req.DramaId, req.Name, req.Description, req.VoiceType, req.PortraitUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
|
||||
}
|
||||
|
||||
func (c *drama) UpdateCharacter(ctx context.Context, req *dto.UpdateCharacterReq) (res *dto.GetDramaRes, err error) {
|
||||
err = service.DramaService.UpdateCharacter(ctx, req.DramaId, req.CharId, req.Name, req.Description, req.VoiceType, req.PortraitUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
|
||||
}
|
||||
|
||||
func (c *drama) DeleteCharacter(ctx context.Context, req *dto.DeleteCharacterReq) (res *dto.GetDramaRes, err error) {
|
||||
err = service.DramaService.DeleteCharacter(ctx, req.DramaId, req.CharId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
|
||||
}
|
||||
|
||||
// ==================== Episode CRUD ====================
|
||||
|
||||
func (c *drama) AddEpisode(ctx context.Context, req *dto.AddEpisodeReq) (res *dto.GetDramaRes, err error) {
|
||||
_, err = service.DramaService.AddEpisode(ctx, req.DramaId, req.Title, req.Script, req.Index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
|
||||
}
|
||||
|
||||
func (c *drama) UpdateEpisode(ctx context.Context, req *dto.UpdateEpisodeReq) (res *dto.GetDramaRes, err error) {
|
||||
err = service.DramaService.UpdateEpisode(ctx, req.DramaId, req.EpId, req.Title, req.Script, req.Index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
|
||||
}
|
||||
|
||||
func (c *drama) DeleteEpisode(ctx context.Context, req *dto.DeleteEpisodeReq) (res *dto.GetDramaRes, err error) {
|
||||
err = service.DramaService.DeleteEpisode(ctx, req.DramaId, req.EpId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
|
||||
}
|
||||
|
||||
// ==================== Generation ====================
|
||||
|
||||
func (c *drama) GenerateEpisode(ctx context.Context, req *dto.GenerateEpisodeReq) (res *struct{}, err error) {
|
||||
return nil, service.DramaService.GenerateEpisode(ctx, req.DramaId, req.EpId)
|
||||
}
|
||||
|
||||
func (c *drama) ContinueSegment(ctx context.Context, req *dto.ContinueSegmentReq) (res *struct{}, err error) {
|
||||
return nil, service.DramaService.ContinueSegment(ctx, req.TaskId)
|
||||
}
|
||||
|
||||
func (c *drama) FeedbackSegment(ctx context.Context, req *dto.FeedbackSegmentReq) (res *struct{}, err error) {
|
||||
return nil, service.DramaService.FeedbackSegment(ctx, req.TaskId, req.Feedback)
|
||||
}
|
||||
|
||||
func (c *drama) GetEpisodeTask(ctx context.Context, req *dto.GetEpisodeTaskReq) (res *dto.GetEpisodeTaskRes, err error) {
|
||||
task, err := service.DramaService.GetEpisodeTask(ctx, req.EpId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.GetEpisodeTaskRes{Task: task}, nil
|
||||
}
|
||||
|
||||
// ==================== 保留旧版 Generate(兼容) ====================
|
||||
|
||||
func (c *drama) Generate(ctx context.Context, req *struct{}) (res *struct{}, err error) {
|
||||
return nil, fmt.Errorf("旧版接口已弃用,请使用单集生成")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"video-factory/shortdrama/model/dto"
|
||||
"video-factory/shortdrama/service"
|
||||
)
|
||||
|
||||
type prompt struct{}
|
||||
|
||||
var Prompt = new(prompt)
|
||||
|
||||
func (c *prompt) List(ctx context.Context, req *dto.ListPromptReq) (res *dto.ListPromptRes, err error) {
|
||||
list := service.PromptService.GetList(ctx)
|
||||
return &dto.ListPromptRes{List: list}, nil
|
||||
}
|
||||
|
||||
func (c *prompt) Get(ctx context.Context, req *dto.GetPromptReq) (res *dto.GetPromptRes, err error) {
|
||||
p := service.PromptService.GetByName(ctx, req.Name)
|
||||
return &dto.GetPromptRes{Prompt: p}, nil
|
||||
}
|
||||
|
||||
func (c *prompt) Save(ctx context.Context, req *dto.SavePromptReq) (res *struct{}, err error) {
|
||||
return nil, service.PromptService.Save(ctx, &req.Prompt)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"video-factory/shortdrama/consts/public"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Character = &characterDao{}
|
||||
|
||||
type characterDao struct{}
|
||||
|
||||
func (d *characterDao) Insert(ctx context.Context, data *entity.Character) (id int64, err error) {
|
||||
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
|
||||
delete(m, "id")
|
||||
delete(m, "created_at")
|
||||
delete(m, "updated_at")
|
||||
delete(m, "deleted_at")
|
||||
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Data(m).Insert()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *characterDao) GetOne(ctx context.Context, id int64) (res *entity.Character, err error) {
|
||||
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("id", id).One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *characterDao) Update(ctx context.Context, id int64, data *entity.Character) error {
|
||||
_, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Data(data).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *characterDao) Delete(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Unscoped().Where("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *characterDao) DeleteByDrama(ctx context.Context, dramaId int64) error {
|
||||
_, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *characterDao) ListByDrama(ctx context.Context, dramaId int64) (res []*entity.Character, err error) {
|
||||
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = make([]*entity.Character, 0)
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"video-factory/shortdrama/consts/public"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Drama = &dramaDao{}
|
||||
|
||||
type dramaDao struct{}
|
||||
|
||||
// AlreadyMigrated 标记是否已执行过加列迁移
|
||||
var alreadyMigrated bool
|
||||
|
||||
func (d *dramaDao) AlterTableAddColumn(ctx context.Context) {
|
||||
if alreadyMigrated {
|
||||
return
|
||||
}
|
||||
alreadyMigrated = true
|
||||
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Where("1=0").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 尝试添加 episode_count 列,列已存在时忽略错误
|
||||
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN episode_count INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
|
||||
func (d *dramaDao) IncrementEpCount(ctx context.Context, dramaId int64) error {
|
||||
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).
|
||||
Data(g.Map{"episode_count": gdb.Raw("episode_count + 1")}).
|
||||
Where("id", dramaId).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *dramaDao) BackfillEpCount(ctx context.Context) error {
|
||||
_, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameDrama+" SET episode_count = (SELECT COUNT(*) FROM "+public.TableNameEpisode+" WHERE "+public.TableNameEpisode+".drama_id = "+public.TableNameDrama+".id)")
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *dramaDao) DecrementEpCount(ctx context.Context, dramaId int64) error {
|
||||
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).
|
||||
Where("id", dramaId).Where("episode_count > 0").
|
||||
Data(g.Map{"episode_count": gdb.Raw("episode_count - 1")}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *dramaDao) Insert(ctx context.Context, data *entity.Drama) (id int64, err error) {
|
||||
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
|
||||
delete(m, "id")
|
||||
delete(m, "created_at")
|
||||
delete(m, "updated_at")
|
||||
delete(m, "deleted_at")
|
||||
r, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Data(m).Insert()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *dramaDao) GetOne(ctx context.Context, id int64) (res *entity.Drama, err error) {
|
||||
r, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Where("id", id).One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *dramaDao) Update(ctx context.Context, id int64, data *entity.Drama) error {
|
||||
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Data(data).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *dramaDao) Delete(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Unscoped().Where("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *dramaDao) List(ctx context.Context) (res []*entity.Drama, err error) {
|
||||
r, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).OrderDesc("id").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = make([]*entity.Drama, 0)
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetCharacters 查询短剧的所有演员
|
||||
func (d *dramaDao) GetCharacters(ctx context.Context, dramaId int64) (res []*entity.Character, err error) {
|
||||
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = make([]*entity.Character, 0)
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetEpisodes 查询短剧的所有剧集
|
||||
func (d *dramaDao) GetEpisodes(ctx context.Context, dramaId int64) (res []*entity.Episode, err error) {
|
||||
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("idx").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = make([]*entity.Episode, 0)
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// ToMap 将 entity 转为 g.Map 用于部分字段更新
|
||||
func (d *dramaDao) ToMap(e *entity.Drama) g.Map {
|
||||
m := make(g.Map)
|
||||
if e.Title != "" {
|
||||
m["title"] = e.Title
|
||||
}
|
||||
if e.Style != "" {
|
||||
m["style"] = e.Style
|
||||
}
|
||||
if e.EpisodeDuration > 0 {
|
||||
m["episode_duration"] = e.EpisodeDuration
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// StructToMap 通用 entity 转 map(非空字段)
|
||||
func StructToMap(obj any) g.Map {
|
||||
m := make(g.Map)
|
||||
b := gconv.Map(obj)
|
||||
for k, v := range b {
|
||||
switch v.(type) {
|
||||
case string:
|
||||
if v.(string) != "" {
|
||||
m[gconv.String(k)] = v
|
||||
}
|
||||
case int:
|
||||
if v.(int) > 0 {
|
||||
m[gconv.String(k)] = v
|
||||
}
|
||||
default:
|
||||
m[gconv.String(k)] = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"video-factory/shortdrama/consts/public"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Episode = &episodeDao{}
|
||||
|
||||
type episodeDao struct{}
|
||||
|
||||
func (d *episodeDao) Insert(ctx context.Context, data *entity.Episode) (id int64, err error) {
|
||||
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
|
||||
delete(m, "id")
|
||||
delete(m, "created_at")
|
||||
delete(m, "updated_at")
|
||||
delete(m, "deleted_at")
|
||||
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(m).Insert()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *episodeDao) GetOne(ctx context.Context, id int64) (res *entity.Episode, err error) {
|
||||
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("id", id).One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *episodeDao) Update(ctx context.Context, id int64, data *entity.Episode) error {
|
||||
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(data).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *episodeDao) UpdateStatus(ctx context.Context, id int64, status string, videoUrl string) error {
|
||||
m := g.Map{"status": status}
|
||||
if videoUrl != "" {
|
||||
m["video_url"] = videoUrl
|
||||
}
|
||||
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(m).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *episodeDao) Delete(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Unscoped().Where("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *episodeDao) DeleteByDrama(ctx context.Context, dramaId int64) error {
|
||||
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *episodeDao) ListByDrama(ctx context.Context, dramaId int64) (res []*entity.Episode, err error) {
|
||||
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("idx").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = make([]*entity.Episode, 0)
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"video-factory/shortdrama/consts/public"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var GenerationTask = &generationTaskDao{}
|
||||
|
||||
type generationTaskDao struct{}
|
||||
|
||||
func (d *generationTaskDao) Insert(ctx context.Context, data *entity.GenerationTask) (id int64, err error) {
|
||||
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
|
||||
delete(m, "id")
|
||||
delete(m, "created_at")
|
||||
delete(m, "updated_at")
|
||||
// generation_task has no deleted_at column
|
||||
// delete(m, "deleted_at")
|
||||
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(m).Insert()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *generationTaskDao) GetOne(ctx context.Context, id int64) (res *entity.GenerationTask, err error) {
|
||||
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Where("id", id).One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetByEpisode 查询某集的最新生成任务
|
||||
func (d *generationTaskDao) GetByEpisode(ctx context.Context, episodeId int64) (res *entity.GenerationTask, err error) {
|
||||
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).
|
||||
Where("episode_id", episodeId).
|
||||
OrderDesc("id").One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *generationTaskDao) Update(ctx context.Context, id int64, data *entity.GenerationTask) error {
|
||||
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(data).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateProgress 更新生成进度
|
||||
func (d *generationTaskDao) UpdateProgress(ctx context.Context, id int64, currentStep int, totalSteps int, stepsData, status string) error {
|
||||
m := g.Map{
|
||||
"current_step": currentStep,
|
||||
"total_steps": totalSteps,
|
||||
"steps_data": stepsData,
|
||||
"status": status,
|
||||
}
|
||||
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(m).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateReview 更新为预览等待状态(用户审核当前段)
|
||||
func (d *generationTaskDao) UpdateReview(ctx context.Context, id int64, currentStep int, stepsData string) error {
|
||||
m := g.Map{
|
||||
"current_step": currentStep,
|
||||
"steps_data": stepsData,
|
||||
"status": "review",
|
||||
}
|
||||
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(m).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStepsData 更新 steps_data 字段(后台轮询器用于回填视频 URL)
|
||||
func (d *generationTaskDao) UpdateStepsData(ctx context.Context, id int64, stepsData string) error {
|
||||
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(g.Map{
|
||||
"steps_data": stepsData,
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByEpisode 删除某集关联的所有生成任务
|
||||
func (d *generationTaskDao) DeleteByEpisode(ctx context.Context, episodeId int64) error {
|
||||
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Where("episode_id", episodeId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByDrama 删除某短剧关联的所有生成任务
|
||||
func (d *generationTaskDao) DeleteByDrama(ctx context.Context, dramaId int64) error {
|
||||
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Where("drama_id", dramaId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateFailed 更新为失败状态
|
||||
func (d *generationTaskDao) UpdateFailed(ctx context.Context, id int64, errMsg string) error {
|
||||
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(g.Map{
|
||||
"status": "failed",
|
||||
"error_message": errMsg,
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListByEpisodeIds 查询多集的最新生成任务(按 episode_id 分组取最新)
|
||||
func (d *generationTaskDao) ListByEpisodeIds(ctx context.Context, episodeIds []int64) (res []*entity.GenerationTask, err error) {
|
||||
if len(episodeIds) == 0 {
|
||||
return []*entity.GenerationTask{}, nil
|
||||
}
|
||||
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).
|
||||
Where("episode_id in (?)", episodeIds).
|
||||
OrderDesc("id").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return []*entity.GenerationTask{}, nil
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// ListByStatus — 按状态查询所有生成任务
|
||||
func (d *generationTaskDao) ListByStatus(ctx context.Context, status string) (res []*entity.GenerationTask, err error) {
|
||||
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).
|
||||
Where("status", status).
|
||||
OrderDesc("id").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return []*entity.GenerationTask{}, nil
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"video-factory/shortdrama/consts/public"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelConfig = &modelConfigDao{}
|
||||
|
||||
type modelConfigDao struct{}
|
||||
|
||||
// CreateTable 创建模型配置表(首次运行时自动调用)
|
||||
func (d *modelConfigDao) CreateTable(ctx context.Context) error {
|
||||
sql := `CREATE TABLE IF NOT EXISTS ` + public.TableNameModelConfig + ` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_api_key TEXT NOT NULL DEFAULT '',
|
||||
video_api_key TEXT NOT NULL DEFAULT '',
|
||||
chat_base_url TEXT NOT NULL DEFAULT '',
|
||||
chat_model_name TEXT NOT NULL DEFAULT '',
|
||||
max_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
temperature REAL NOT NULL DEFAULT 0,
|
||||
video_base_url TEXT NOT NULL DEFAULT '',
|
||||
video_model_name TEXT NOT NULL DEFAULT '',
|
||||
video_query_url TEXT NOT NULL DEFAULT '',
|
||||
max_single_duration INTEGER NOT NULL DEFAULT 0,
|
||||
min_single_duration INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`
|
||||
_, err := g.DB().Exec(ctx, sql)
|
||||
return err
|
||||
}
|
||||
|
||||
// AlterTableMigrate 迁移旧表:rename api_key → chat_api_key,add video_api_key
|
||||
var modelConfigMigrated bool
|
||||
|
||||
func (d *modelConfigDao) AlterTableMigrate(ctx context.Context) {
|
||||
if modelConfigMigrated {
|
||||
return
|
||||
}
|
||||
modelConfigMigrated = true
|
||||
// 检查表是否存在
|
||||
_, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Where("1=0").All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 查询当前表结构
|
||||
cols, _ := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameModelConfig+")")
|
||||
if cols == nil {
|
||||
return
|
||||
}
|
||||
hasOldColumn := false
|
||||
hasVideoKey := false
|
||||
for _, col := range cols {
|
||||
name := col["name"].String()
|
||||
if name == "api_key" {
|
||||
hasOldColumn = true
|
||||
}
|
||||
if name == "video_api_key" {
|
||||
hasVideoKey = true
|
||||
}
|
||||
}
|
||||
if hasOldColumn {
|
||||
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" RENAME COLUMN api_key TO chat_api_key")
|
||||
}
|
||||
if !hasVideoKey {
|
||||
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" ADD COLUMN video_api_key TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
hasQueryUrl := false
|
||||
for _, col := range cols {
|
||||
if col["name"].String() == "video_query_url" {
|
||||
hasQueryUrl = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasQueryUrl {
|
||||
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" ADD COLUMN video_query_url TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
// GetFirst 获取第一条配置行
|
||||
func (d *modelConfigDao) GetFirst(ctx context.Context) (res *entity.ModelConfig, err error) {
|
||||
r, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").Limit(1).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// Save 保存配置:存在则更新,不存在则插入
|
||||
func (d *modelConfigDao) Save(ctx context.Context, data *entity.ModelConfig) error {
|
||||
existing, err := d.GetFirst(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
data.Id = existing.Id
|
||||
_, err = g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(data).Where("id", existing.Id).Update()
|
||||
return err
|
||||
}
|
||||
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
|
||||
delete(m, "id")
|
||||
delete(m, "created_at")
|
||||
delete(m, "updated_at")
|
||||
_, err = g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(m).Insert()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"video-factory/shortdrama/consts/public"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var Prompt = &promptDao{}
|
||||
|
||||
type promptDao struct{}
|
||||
|
||||
// CreateTable 创建提示词表(首次运行时自动调用)
|
||||
func (d *promptDao) CreateTable(ctx context.Context) error {
|
||||
sql := `CREATE TABLE IF NOT EXISTS ` + public.TableNamePrompt + ` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`
|
||||
_, err := g.DB().Exec(ctx, sql)
|
||||
return err
|
||||
}
|
||||
|
||||
// List 获取所有提示词列表
|
||||
func (d *promptDao) List(ctx context.Context) (res []*entity.Prompt, err error) {
|
||||
r, err := g.DB().Model(public.TableNamePrompt).Ctx(ctx).OrderAsc("id").All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取提示词
|
||||
func (d *promptDao) GetByName(ctx context.Context, name string) (res *entity.Prompt, err error) {
|
||||
r, err := g.DB().Model(public.TableNamePrompt).Ctx(ctx).Where("name", name).Limit(1).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// Save 保存提示词:存在则更新,不存在则插入
|
||||
func (d *promptDao) Save(ctx context.Context, data *entity.Prompt) error {
|
||||
existing, err := d.GetByName(ctx, data.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
data.Id = existing.Id
|
||||
_, err = g.DB().Model(public.TableNamePrompt).Ctx(ctx).Data(data).Where("id", existing.Id).Update()
|
||||
return err
|
||||
}
|
||||
_, err = g.DB().Model(public.TableNamePrompt).Ctx(ctx).Data(data).Insert()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentOutput Agent 最终输出的 JSON 结构
|
||||
type AgentOutput struct {
|
||||
Title string `json:"title"`
|
||||
TotalEpisodes int `json:"total_episodes"`
|
||||
TotalDuration int `json:"total_duration"`
|
||||
Episodes []AgentEpisode `json:"episodes"`
|
||||
Characters []AgentCharacter `json:"characters"`
|
||||
}
|
||||
|
||||
// AgentEpisode 剧集部分
|
||||
type AgentEpisode struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"`
|
||||
Scenes []AgentScene `json:"scenes"`
|
||||
}
|
||||
|
||||
// AgentScene 场景部分
|
||||
type AgentScene struct {
|
||||
Index int `json:"index"`
|
||||
Description string `json:"description"`
|
||||
Lines string `json:"lines"`
|
||||
Duration int `json:"duration"`
|
||||
Characters []string `json:"characters"`
|
||||
VisualDesc string `json:"visualDesc"`
|
||||
ImageUrl string `json:"imageUrl"`
|
||||
}
|
||||
|
||||
// AgentCharacter 演员部分
|
||||
type AgentCharacter struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageUrl string `json:"imageUrl"`
|
||||
}
|
||||
|
||||
// extractJSON 从可能含 markdown 代码块标记或额外说明文字的文本中提取 JSON 字符串
|
||||
func extractJSON(s string) string {
|
||||
// 尝试直接解析
|
||||
if json.Valid([]byte(s)) {
|
||||
return s
|
||||
}
|
||||
// 尝试从 ```json ... ``` 代码块中提取
|
||||
const jsonPrefix = "```json"
|
||||
const codeFence = "```"
|
||||
if idx := strings.Index(s, jsonPrefix); idx >= 0 {
|
||||
start := idx + len(jsonPrefix)
|
||||
if end := strings.Index(s[start:], codeFence); end >= 0 {
|
||||
trimmed := strings.TrimSpace(s[start : start+end])
|
||||
if json.Valid([]byte(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
// 尝试从 ``` 代码块中提取
|
||||
if idx := strings.Index(s, codeFence); idx >= 0 {
|
||||
start := idx + len(codeFence)
|
||||
if end := strings.Index(s[start:], codeFence); end >= 0 {
|
||||
trimmed := strings.TrimSpace(s[start : start+end])
|
||||
if json.Valid([]byte(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
// 最后手段:查找第一个 { 和最后一个 } 截取 JSON
|
||||
if start := strings.Index(s, "{"); start >= 0 {
|
||||
if end := strings.LastIndex(s, "}"); end > start {
|
||||
candidate := s[start : end+1]
|
||||
if json.Valid([]byte(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ParseAgentOutput 解析 Agent 输出的 JSON 字符串为 SegmentOutput
|
||||
// 如果解析失败,返回包含原始文本的 SegmentOutput
|
||||
func ParseAgentOutput(jsonStr string, segIdx int) *SegmentOutput {
|
||||
out := &SegmentOutput{
|
||||
Index: segIdx,
|
||||
Confirmed: false,
|
||||
TextOutput: jsonStr,
|
||||
}
|
||||
|
||||
extracted := extractJSON(jsonStr)
|
||||
var agentOut AgentOutput
|
||||
if err := json.Unmarshal([]byte(extracted), &agentOut); err != nil {
|
||||
return out
|
||||
}
|
||||
|
||||
// 提取演员
|
||||
for _, c := range agentOut.Characters {
|
||||
out.Characters = append(out.Characters, SegmentCharacter{
|
||||
Name: c.Name,
|
||||
Description: c.Description,
|
||||
ImageBase64: stripImagePrefix(c.ImageUrl),
|
||||
})
|
||||
}
|
||||
|
||||
// 提取场景(取第一集,因为分段生成只涉及一集)
|
||||
if len(agentOut.Episodes) > 0 {
|
||||
for _, s := range agentOut.Episodes[0].Scenes {
|
||||
out.Scenes = append(out.Scenes, SegmentScene{
|
||||
Index: s.Index,
|
||||
Description: s.Description,
|
||||
Lines: s.Lines,
|
||||
Duration: s.Duration,
|
||||
Characters: s.Characters,
|
||||
ImageBase64: stripImagePrefix(s.ImageUrl),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// stripImagePrefix 去掉 "data:image/png;base64," 等前缀
|
||||
func stripImagePrefix(s string) string {
|
||||
for _, prefix := range []string{
|
||||
"data:image/png;base64,",
|
||||
"data:image/jpeg;base64,",
|
||||
"data:image/webp;base64,",
|
||||
"data:image/",
|
||||
} {
|
||||
if len(s) > len(prefix) && s[:len(prefix)] == prefix {
|
||||
return s[len(prefix):]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type AddCharacterReq struct {
|
||||
g.Meta `path:"/character/add" method:"post" tags:"短剧管理" summary:"添加演员"`
|
||||
DramaId int64 `json:"dramaId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
VoiceType string `json:"voiceType"`
|
||||
PortraitUrl string `json:"portraitUrl"`
|
||||
}
|
||||
|
||||
type UpdateCharacterReq struct {
|
||||
g.Meta `path:"/character/update" method:"post" tags:"短剧管理" summary:"更新演员"`
|
||||
DramaId int64 `json:"dramaId"`
|
||||
CharId int64 `json:"charId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
VoiceType string `json:"voiceType"`
|
||||
PortraitUrl string `json:"portraitUrl"`
|
||||
}
|
||||
|
||||
type DeleteCharacterReq struct {
|
||||
g.Meta `path:"/character/delete" method:"post" tags:"短剧管理" summary:"删除演员"`
|
||||
DramaId int64 `json:"dramaId"`
|
||||
CharId int64 `json:"charId"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"video-factory/shortdrama/model"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type GetModelConfigReq struct {
|
||||
g.Meta `path:"/model" method:"get" tags:"模型配置" summary:"获取模型配置"`
|
||||
}
|
||||
|
||||
type GetModelConfigRes struct {
|
||||
*model.ModelConfig
|
||||
}
|
||||
|
||||
type SaveModelConfigReq struct {
|
||||
g.Meta `path:"/model" method:"post" tags:"模型配置" summary:"保存模型配置"`
|
||||
model.ModelConfig
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ==================== List ====================
|
||||
|
||||
type ListDramaReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"短剧列表"`
|
||||
}
|
||||
|
||||
type ListDramaRes struct {
|
||||
List []*entity.Drama `json:"list"`
|
||||
}
|
||||
|
||||
// ==================== Create ====================
|
||||
|
||||
type CreateDramaReq struct {
|
||||
g.Meta `path:"/create" method:"post" tags:"短剧管理" summary:"创建短剧"`
|
||||
Title string `json:"title"`
|
||||
Style string `json:"style"`
|
||||
EpisodeDuration int `json:"episodeDuration"`
|
||||
}
|
||||
|
||||
type CreateDramaRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// ==================== Get ====================
|
||||
|
||||
type GetDramaReq struct {
|
||||
g.Meta `path:"/get" method:"get" tags:"短剧管理" summary:"获取短剧详情"`
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// GetDramaRes 包含短剧及其关联的演员、剧集和生成任务
|
||||
type GetDramaRes struct {
|
||||
*entity.Drama
|
||||
Characters []*entity.Character `json:"characters"`
|
||||
Episodes []*entity.Episode `json:"episodes"`
|
||||
Tasks []*entity.GenerationTask `json:"tasks"`
|
||||
}
|
||||
|
||||
// ==================== Update ====================
|
||||
|
||||
type UpdateDramaReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新短剧"`
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Style string `json:"style"`
|
||||
EpisodeDuration int `json:"episodeDuration"`
|
||||
}
|
||||
|
||||
// ==================== Delete ====================
|
||||
|
||||
type DeleteDramaReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除短剧"`
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type AddEpisodeReq struct {
|
||||
g.Meta `path:"/episode/add" method:"post" tags:"短剧管理" summary:"添加剧集"`
|
||||
DramaId int64 `json:"dramaId"`
|
||||
Title string `json:"title"`
|
||||
Script string `json:"script"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
type UpdateEpisodeReq struct {
|
||||
g.Meta `path:"/episode/update" method:"post" tags:"短剧管理" summary:"更新剧集"`
|
||||
DramaId int64 `json:"dramaId"`
|
||||
EpId int64 `json:"epId"`
|
||||
Title string `json:"title"`
|
||||
Script string `json:"script"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
type DeleteEpisodeReq struct {
|
||||
g.Meta `path:"/episode/delete" method:"post" tags:"短剧管理" summary:"删除剧集"`
|
||||
DramaId int64 `json:"dramaId"`
|
||||
EpId int64 `json:"epId"`
|
||||
}
|
||||
|
||||
type GenerateEpisodeReq struct {
|
||||
g.Meta `path:"/episode/generate" method:"post" tags:"短剧管理" summary:"单集生成视频"`
|
||||
DramaId int64 `json:"dramaId"`
|
||||
EpId int64 `json:"epId"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"video-factory/shortdrama/model"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type ListPromptReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"提示词" summary:"获取提示词列表"`
|
||||
}
|
||||
|
||||
type ListPromptRes struct {
|
||||
List []*model.Prompt `json:"list"`
|
||||
}
|
||||
|
||||
type GetPromptReq struct {
|
||||
g.Meta `path:"/get" method:"get" tags:"提示词" summary:"获取单个提示词"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type GetPromptRes struct {
|
||||
*model.Prompt
|
||||
}
|
||||
|
||||
type SavePromptReq struct {
|
||||
g.Meta `path:"/save" method:"post" tags:"提示词" summary:"保存提示词"`
|
||||
model.Prompt
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ContinueSegmentReq 继续下一段
|
||||
type ContinueSegmentReq struct {
|
||||
g.Meta `path:"/segment/continue" method:"post" tags:"短剧生成" summary:"确认当前段并继续下一段"`
|
||||
TaskId int64 `json:"taskId"`
|
||||
}
|
||||
|
||||
// FeedbackSegmentReq 反馈重新生成
|
||||
type FeedbackSegmentReq struct {
|
||||
g.Meta `path:"/segment/feedback" method:"post" tags:"短剧生成" summary:"为当前段提供反馈并重新生成"`
|
||||
TaskId int64 `json:"taskId"`
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
|
||||
// GetEpisodeTaskReq 获取剧集当前任务
|
||||
type GetEpisodeTaskReq struct {
|
||||
g.Meta `path:"/episode/task" method:"get" tags:"短剧生成" summary:"获取某集当前生成任务"`
|
||||
EpId int64 `json:"epId"`
|
||||
}
|
||||
|
||||
// GetEpisodeTaskRes 获取某集当前任务的响应
|
||||
type GetEpisodeTaskRes struct {
|
||||
Task *entity.GenerationTask `json:"task"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type Character struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
DramaId int64 `orm:"drama_id" json:"dramaId"`
|
||||
Name string `orm:"name" json:"name"`
|
||||
Description string `orm:"description" json:"description"`
|
||||
VoiceType string `orm:"voice_type" json:"voiceType"`
|
||||
PortraitUrl string `orm:"portrait_url" json:"portraitUrl"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
|
||||
DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type Drama struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
Title string `orm:"title" json:"title"`
|
||||
Style string `orm:"style" json:"style"`
|
||||
EpisodeDuration int64 `orm:"episode_duration" json:"episodeDuration"`
|
||||
EpCount int `orm:"episode_count" json:"epCount"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
|
||||
DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type Episode struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
DramaId int64 `orm:"drama_id" json:"dramaId"`
|
||||
Index int `orm:"idx" json:"index"`
|
||||
Title string `orm:"title" json:"title"`
|
||||
Script string `orm:"script" json:"script"`
|
||||
Status string `orm:"status" json:"status"`
|
||||
VideoUrl string `orm:"video_url" json:"videoUrl"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
|
||||
DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type GenerationTask struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
DramaId int64 `orm:"drama_id" json:"dramaId"`
|
||||
EpisodeId int64 `orm:"episode_id" json:"episodeId"`
|
||||
Status string `orm:"status" json:"status"`
|
||||
CurrentStep int `orm:"current_step" json:"currentStep"`
|
||||
TotalSteps int `orm:"total_steps" json:"totalSteps"`
|
||||
StepsData string `orm:"steps_data" json:"stepsData"`
|
||||
ErrorMessage string `orm:"error_message" json:"errorMessage"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type ModelConfig struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
ChatApiKey string `orm:"chat_api_key" json:"chatApiKey"`
|
||||
VideoApiKey string `orm:"video_api_key" json:"videoApiKey"`
|
||||
ChatBaseUrl string `orm:"chat_base_url" json:"chatBaseUrl"`
|
||||
ChatModelName string `orm:"chat_model_name" json:"chatModelName"`
|
||||
MaxTokens int `orm:"max_tokens" json:"maxTokens"`
|
||||
Temperature float64 `orm:"temperature" json:"temperature"`
|
||||
VideoBaseUrl string `orm:"video_base_url" json:"videoBaseUrl"`
|
||||
VideoModelName string `orm:"video_model_name" json:"videoModelName"`
|
||||
VideoQueryUrl string `orm:"video_query_url" json:"videoQueryUrl"`
|
||||
MaxSingleDuration int `orm:"max_single_duration" json:"maxSingleDuration"`
|
||||
MinSingleDuration int `orm:"min_single_duration" json:"minSingleDuration"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Prompt struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
Name string `orm:"name" json:"name"`
|
||||
Content string `orm:"content" json:"content"`
|
||||
Remark string `orm:"remark" json:"remark"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
// ModelConfig 模型配置(config.yml 映射)
|
||||
type ModelConfig struct {
|
||||
ChatApiKey string `json:"chatApiKey"`
|
||||
VideoApiKey string `json:"videoApiKey"`
|
||||
ChatBaseURL string `json:"chatBaseUrl"`
|
||||
ChatModelName string `json:"chatModelName"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
VideoBaseURL string `json:"videoBaseUrl"`
|
||||
VideoModelName string `json:"videoModelName"`
|
||||
VideoQueryURL string `json:"videoQueryUrl"`
|
||||
MaxSingleDuration int `json:"maxSingleDuration"`
|
||||
MinSingleDuration int `json:"minSingleDuration"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
type Prompt struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
// SegmentOutput 单段生成输出(用于预览和 JSON 序列化)
|
||||
type SegmentOutput struct {
|
||||
Index int `json:"index"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
TextOutput string `json:"textOutput"`
|
||||
Scenes []SegmentScene `json:"scenes"`
|
||||
Characters []SegmentCharacter `json:"characters"`
|
||||
Feedback string `json:"feedback,omitempty"`
|
||||
VideoURL string `json:"videoUrl"`
|
||||
VideoTaskId string `json:"videoTaskId,omitempty"`
|
||||
}
|
||||
|
||||
// StepsData StepsData 字段的完整 JSON 结构
|
||||
type StepsData struct {
|
||||
Segments []SegmentOutput `json:"segments"`
|
||||
CurrentSegment int `json:"currentSegment"`
|
||||
LastFrame string `json:"lastFrame"`
|
||||
VideoTaskId string `json:"videoTaskId,omitempty"`
|
||||
}
|
||||
|
||||
// SegmentScene 单段中的一个场景
|
||||
type SegmentScene struct {
|
||||
Index int `json:"index"`
|
||||
Description string `json:"description"`
|
||||
Lines string `json:"lines"`
|
||||
Duration int `json:"duration"`
|
||||
Characters []string `json:"characters"`
|
||||
ImageBase64 string `json:"imageBase64"`
|
||||
}
|
||||
|
||||
// SegmentCharacter 单段中的一个演员
|
||||
type SegmentCharacter struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageBase64 string `json:"imageBase64"`
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"video-factory/shortdrama/dao"
|
||||
"video-factory/shortdrama/model"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
)
|
||||
|
||||
// ConfigService 模型配置服务
|
||||
type configService struct{}
|
||||
|
||||
var ConfigService = new(configService)
|
||||
|
||||
// Get 从数据库获取模型配置
|
||||
func (s *configService) Get(ctx context.Context) *model.ModelConfig {
|
||||
_ = dao.ModelConfig.CreateTable(ctx)
|
||||
dao.ModelConfig.AlterTableMigrate(ctx)
|
||||
|
||||
cfg, err := dao.ModelConfig.GetFirst(ctx)
|
||||
if err == nil && cfg != nil {
|
||||
return s.entityToModel(cfg)
|
||||
}
|
||||
return &model.ModelConfig{}
|
||||
}
|
||||
|
||||
// Save 保存模型配置到数据库
|
||||
func (s *configService) Save(ctx context.Context, cfg *model.ModelConfig) error {
|
||||
_ = dao.ModelConfig.CreateTable(ctx)
|
||||
dao.ModelConfig.AlterTableMigrate(ctx)
|
||||
|
||||
existing, _ := dao.ModelConfig.GetFirst(ctx)
|
||||
if existing != nil {
|
||||
if cfg.ChatApiKey != "" {
|
||||
existing.ChatApiKey = cfg.ChatApiKey
|
||||
}
|
||||
if cfg.VideoApiKey != "" {
|
||||
existing.VideoApiKey = cfg.VideoApiKey
|
||||
}
|
||||
if cfg.ChatBaseURL != "" {
|
||||
existing.ChatBaseUrl = cfg.ChatBaseURL
|
||||
}
|
||||
if cfg.ChatModelName != "" {
|
||||
existing.ChatModelName = cfg.ChatModelName
|
||||
}
|
||||
if cfg.VideoBaseURL != "" {
|
||||
existing.VideoBaseUrl = cfg.VideoBaseURL
|
||||
}
|
||||
if cfg.VideoModelName != "" {
|
||||
existing.VideoModelName = cfg.VideoModelName
|
||||
}
|
||||
if cfg.VideoQueryURL != "" {
|
||||
existing.VideoQueryUrl = cfg.VideoQueryURL
|
||||
}
|
||||
if cfg.MaxTokens > 0 {
|
||||
existing.MaxTokens = cfg.MaxTokens
|
||||
}
|
||||
if cfg.Temperature > 0 {
|
||||
existing.Temperature = cfg.Temperature
|
||||
}
|
||||
if cfg.MaxSingleDuration > 0 {
|
||||
existing.MaxSingleDuration = cfg.MaxSingleDuration
|
||||
}
|
||||
if cfg.MinSingleDuration > 0 {
|
||||
existing.MinSingleDuration = cfg.MinSingleDuration
|
||||
}
|
||||
return dao.ModelConfig.Save(ctx, existing)
|
||||
}
|
||||
|
||||
return dao.ModelConfig.Save(ctx, s.modelToEntity(cfg))
|
||||
}
|
||||
|
||||
func (s *configService) entityToModel(e *entity.ModelConfig) *model.ModelConfig {
|
||||
return &model.ModelConfig{
|
||||
ChatApiKey: e.ChatApiKey,
|
||||
VideoApiKey: e.VideoApiKey,
|
||||
ChatBaseURL: e.ChatBaseUrl,
|
||||
ChatModelName: e.ChatModelName,
|
||||
MaxTokens: e.MaxTokens,
|
||||
Temperature: e.Temperature,
|
||||
VideoBaseURL: e.VideoBaseUrl,
|
||||
VideoModelName: e.VideoModelName,
|
||||
VideoQueryURL: e.VideoQueryUrl,
|
||||
MaxSingleDuration: e.MaxSingleDuration,
|
||||
MinSingleDuration: e.MinSingleDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *configService) modelToEntity(cfg *model.ModelConfig) *entity.ModelConfig {
|
||||
return &entity.ModelConfig{
|
||||
ChatApiKey: cfg.ChatApiKey,
|
||||
VideoApiKey: cfg.VideoApiKey,
|
||||
ChatBaseUrl: cfg.ChatBaseURL,
|
||||
ChatModelName: cfg.ChatModelName,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
Temperature: cfg.Temperature,
|
||||
VideoBaseUrl: cfg.VideoBaseURL,
|
||||
VideoModelName: cfg.VideoModelName,
|
||||
VideoQueryUrl: cfg.VideoQueryURL,
|
||||
MaxSingleDuration: cfg.MaxSingleDuration,
|
||||
MinSingleDuration: cfg.MinSingleDuration,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"video-factory/shortdrama/agent"
|
||||
"video-factory/shortdrama/dao"
|
||||
"video-factory/shortdrama/model"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// Migrate 数据库迁移:加列、回填现有数据
|
||||
func (s *dramaService) Migrate(ctx context.Context) {
|
||||
dao.Drama.AlterTableAddColumn(ctx)
|
||||
|
||||
_ = dao.Drama.BackfillEpCount(ctx)
|
||||
}
|
||||
|
||||
// ==================== Drama CRUD ====================
|
||||
|
||||
type dramaService struct{}
|
||||
|
||||
var DramaService = new(dramaService)
|
||||
|
||||
func (s *dramaService) Create(ctx context.Context, title, style string, episodeDuration int64) (int64, error) {
|
||||
return dao.Drama.Insert(ctx, &entity.Drama{
|
||||
Title: title,
|
||||
Style: style,
|
||||
EpisodeDuration: episodeDuration,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *dramaService) List(ctx context.Context) ([]*entity.Drama, error) {
|
||||
return dao.Drama.List(ctx)
|
||||
}
|
||||
|
||||
func (s *dramaService) Get(ctx context.Context, id int64) (*entity.Drama, []*entity.Character, []*entity.Episode, []*entity.GenerationTask, error) {
|
||||
d, err := dao.Drama.GetOne(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
if d == nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("短剧不存在: %d", id)
|
||||
}
|
||||
|
||||
characters, err := dao.Character.ListByDrama(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
episodes, err := dao.Episode.ListByDrama(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
var episodeIds []int64
|
||||
for _, ep := range episodes {
|
||||
episodeIds = append(episodeIds, ep.Id)
|
||||
}
|
||||
var tasks []*entity.GenerationTask
|
||||
if len(episodeIds) > 0 {
|
||||
tasks, _ = dao.GenerationTask.ListByEpisodeIds(ctx, episodeIds)
|
||||
}
|
||||
|
||||
return d, characters, episodes, tasks, nil
|
||||
}
|
||||
|
||||
func (s *dramaService) Update(ctx context.Context, id int64, title, style string, episodeDuration int64) error {
|
||||
d, err := dao.Drama.GetOne(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d == nil {
|
||||
return fmt.Errorf("短剧不存在: %d", id)
|
||||
}
|
||||
if title != "" {
|
||||
d.Title = title
|
||||
}
|
||||
if style != "" {
|
||||
d.Style = style
|
||||
}
|
||||
if episodeDuration > 0 {
|
||||
d.EpisodeDuration = episodeDuration
|
||||
}
|
||||
return dao.Drama.Update(ctx, id, d)
|
||||
}
|
||||
|
||||
func (s *dramaService) Delete(ctx context.Context, id int64) error {
|
||||
_ = dao.Character.DeleteByDrama(ctx, id)
|
||||
_ = dao.Episode.DeleteByDrama(ctx, id)
|
||||
_ = dao.GenerationTask.DeleteByDrama(ctx, id)
|
||||
return dao.Drama.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ==================== Character CRUD ====================
|
||||
|
||||
func (s *dramaService) AddCharacter(ctx context.Context, dramaId int64, name, description, voiceType, portraitUrl string) (int64, error) {
|
||||
return dao.Character.Insert(ctx, &entity.Character{
|
||||
DramaId: dramaId,
|
||||
Name: name,
|
||||
Description: description,
|
||||
VoiceType: voiceType,
|
||||
PortraitUrl: portraitUrl,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *dramaService) UpdateCharacter(ctx context.Context, dramaId, charId int64, name, description, voiceType, portraitUrl string) error {
|
||||
c, err := dao.Character.GetOne(ctx, charId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == nil {
|
||||
return fmt.Errorf("演员不存在: %d", charId)
|
||||
}
|
||||
if name != "" {
|
||||
c.Name = name
|
||||
}
|
||||
if description != "" {
|
||||
c.Description = description
|
||||
}
|
||||
if voiceType != "" {
|
||||
c.VoiceType = voiceType
|
||||
}
|
||||
if portraitUrl != "" {
|
||||
c.PortraitUrl = portraitUrl
|
||||
}
|
||||
return dao.Character.Update(ctx, charId, c)
|
||||
}
|
||||
|
||||
func (s *dramaService) DeleteCharacter(ctx context.Context, dramaId, charId int64) error {
|
||||
return dao.Character.Delete(ctx, charId)
|
||||
}
|
||||
|
||||
// ==================== Episode CRUD ====================
|
||||
|
||||
func (s *dramaService) AddEpisode(ctx context.Context, dramaId int64, title, script string, index int) (int64, error) {
|
||||
id, err := dao.Episode.Insert(ctx, &entity.Episode{
|
||||
DramaId: dramaId,
|
||||
Index: index,
|
||||
Title: title,
|
||||
Script: script,
|
||||
Status: "pending",
|
||||
})
|
||||
if err == nil {
|
||||
_ = dao.Drama.IncrementEpCount(ctx, dramaId)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, title, script string, index int) error {
|
||||
e, err := dao.Episode.GetOne(ctx, epId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e == nil {
|
||||
return fmt.Errorf("剧集不存在: %d", epId)
|
||||
}
|
||||
if title != "" {
|
||||
e.Title = title
|
||||
}
|
||||
if script != "" {
|
||||
e.Script = script
|
||||
}
|
||||
if index > 0 {
|
||||
e.Index = index
|
||||
}
|
||||
return dao.Episode.Update(ctx, epId, e)
|
||||
}
|
||||
|
||||
func (s *dramaService) DeleteEpisode(ctx context.Context, dramaId, epId int64) error {
|
||||
_ = dao.GenerationTask.DeleteByEpisode(ctx, epId)
|
||||
err := dao.Episode.Delete(ctx, epId)
|
||||
if err == nil {
|
||||
_ = dao.Drama.DecrementEpCount(ctx, dramaId)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ==================== Generation ====================
|
||||
|
||||
// GenerateEpisode 生成某集视频
|
||||
func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64) error {
|
||||
d, err := dao.Drama.GetOne(ctx, dramaId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d == nil {
|
||||
return fmt.Errorf("短剧不存在")
|
||||
}
|
||||
|
||||
ep, err := dao.Episode.GetOne(ctx, epId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ep == nil {
|
||||
return fmt.Errorf("剧集不存在")
|
||||
}
|
||||
|
||||
modelCfg := ConfigService.Get(ctx)
|
||||
if modelCfg.ChatApiKey == "" || modelCfg.ChatModelName == "" {
|
||||
return fmt.Errorf("模型未配置")
|
||||
}
|
||||
|
||||
ctx = agent.WithModelConfig(ctx, &agent.ModelConfig{
|
||||
APIKey: modelCfg.ChatApiKey,
|
||||
BaseURL: modelCfg.ChatBaseURL,
|
||||
})
|
||||
|
||||
task, err := dao.GenerationTask.GetByEpisode(ctx, epId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
startStep := 0
|
||||
prevLastFrame := ""
|
||||
|
||||
if task != nil && task.Status == "failed" && task.CurrentStep > 0 {
|
||||
startStep = task.CurrentStep
|
||||
var sd map[string]any
|
||||
if err := json.Unmarshal([]byte(task.StepsData), &sd); err == nil {
|
||||
if lastFrame, ok := sd["lastFrame"]; ok {
|
||||
prevLastFrame = fmt.Sprintf("%v", lastFrame)
|
||||
}
|
||||
}
|
||||
task.Status = "generating"
|
||||
task.ErrorMessage = ""
|
||||
_ = dao.GenerationTask.Update(ctx, task.Id, task)
|
||||
} else {
|
||||
task = &entity.GenerationTask{
|
||||
DramaId: dramaId,
|
||||
EpisodeId: epId,
|
||||
Status: "generating",
|
||||
CurrentStep: 0,
|
||||
StepsData: "{}",
|
||||
}
|
||||
id, err := dao.GenerationTask.Insert(ctx, task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task.Id = id
|
||||
}
|
||||
|
||||
_ = dao.Episode.UpdateStatus(ctx, epId, "generating", "")
|
||||
|
||||
go func() {
|
||||
genCtx := context.Background()
|
||||
genCtx = agent.WithModelConfig(genCtx, &agent.ModelConfig{
|
||||
APIKey: modelCfg.ChatApiKey,
|
||||
BaseURL: modelCfg.ChatBaseURL,
|
||||
})
|
||||
if err := s.generateOneSegment(genCtx, d, ep, task.Id, startStep, prevLastFrame, ""); err != nil {
|
||||
g.Log().Errorf(genCtx, "第%d集第%d段生成失败: %v", ep.Index, startStep+1, err)
|
||||
_ = dao.GenerationTask.UpdateFailed(ctx, task.Id, err.Error())
|
||||
_ = dao.Episode.UpdateStatus(ctx, epId, "failed", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateOneSegment 生成一段内容,完成后进入 review 状态
|
||||
func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64, segIdx int, prevLastFrame, feedback string) error {
|
||||
modelCfg := ConfigService.Get(ctx)
|
||||
|
||||
duration := int(d.EpisodeDuration)
|
||||
if duration <= 0 {
|
||||
duration = 60
|
||||
}
|
||||
maxSingle := modelCfg.MaxSingleDuration
|
||||
minSingle := modelCfg.MinSingleDuration
|
||||
if maxSingle <= 0 {
|
||||
maxSingle = 30
|
||||
}
|
||||
if minSingle <= 0 {
|
||||
minSingle = maxSingle
|
||||
}
|
||||
segDurs := calcSegmentDurations(duration, maxSingle, minSingle)
|
||||
numSegments := len(segDurs)
|
||||
segDur := segDurs[segIdx]
|
||||
|
||||
prevSummary := ""
|
||||
task, err := dao.GenerationTask.GetOne(ctx, taskId)
|
||||
if err == nil && task != nil && task.StepsData != "" && task.StepsData != "{}" {
|
||||
var sd model.StepsData
|
||||
if err := json.Unmarshal([]byte(task.StepsData), &sd); err == nil && segIdx > 0 {
|
||||
if segIdx-1 < len(sd.Segments) && sd.Segments[segIdx-1].TextOutput != "" {
|
||||
prevSummary = sd.Segments[segIdx-1].TextOutput
|
||||
if len(prevSummary) > 500 {
|
||||
prevSummary = prevSummary[:500]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, prevLastFrame, modelCfg, feedback, prevSummary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
segOutput := model.ParseAgentOutput(result, segIdx)
|
||||
|
||||
task, err = dao.GenerationTask.GetOne(ctx, taskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sd model.StepsData
|
||||
if task.StepsData != "" && task.StepsData != "{}" {
|
||||
_ = json.Unmarshal([]byte(task.StepsData), &sd)
|
||||
}
|
||||
if sd.Segments == nil {
|
||||
sd.Segments = make([]model.SegmentOutput, numSegments)
|
||||
}
|
||||
sd.Segments[segIdx] = *segOutput
|
||||
sd.CurrentSegment = segIdx
|
||||
|
||||
lastFrame := prevLastFrame
|
||||
for _, s := range segOutput.Scenes {
|
||||
if s.ImageBase64 != "" {
|
||||
lastFrame = s.ImageBase64
|
||||
}
|
||||
}
|
||||
sd.LastFrame = lastFrame
|
||||
|
||||
stepsDataBytes, _ := json.Marshal(sd)
|
||||
|
||||
// 提交视频合成任务(只提交,不阻塞等待轮询结果)
|
||||
taskID, submitErr := s.submitVideoTask(ctx, d, ep, segIdx, &sd.Segments[segIdx])
|
||||
if submitErr != nil {
|
||||
g.Log().Warningf(ctx, "第%d段视频提交失败,后台将继续重试: %v", segIdx+1, submitErr)
|
||||
} else {
|
||||
sd.Segments[segIdx].VideoTaskId = taskID
|
||||
}
|
||||
stepsDataBytes, _ = json.Marshal(sd)
|
||||
|
||||
if segIdx >= numSegments-1 {
|
||||
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, segIdx, numSegments, string(stepsDataBytes), "generating")
|
||||
g.Log().Infof(ctx, "第%d集所有段落剧情生成完成,等待视频生成", ep.Index)
|
||||
} else {
|
||||
nextSeg := segIdx + 1
|
||||
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, nextSeg, numSegments, string(stepsDataBytes), "generating")
|
||||
g.Log().Infof(ctx, "第%d集第%d段剧情生成完成,等待视频生成", ep.Index, segIdx+1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSegment 调用 Agent 生成一段,返回 agent 输出文本
|
||||
func (s *dramaService) generateSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode,
|
||||
segIdx, segDur int, prevLastFrame string, modelCfg *model.ModelConfig, feedback string, prevSummary string) (string, error) {
|
||||
|
||||
chatModel, err := agent.NewChatModel(ctx, &agent.ModelConfig{
|
||||
ModelName: modelCfg.ChatModelName,
|
||||
APIKey: modelCfg.ChatApiKey,
|
||||
BaseURL: modelCfg.ChatBaseURL,
|
||||
MaxTokens: modelCfg.MaxTokens,
|
||||
Temperature: float32(modelCfg.Temperature),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tools := agent.GetTools()
|
||||
systemPrompt := s.buildSegPrompt(ctx, d, ep, segIdx, segDur, prevLastFrame)
|
||||
reactAgent := agent.NewReActAgent(ctx, chatModel, tools, systemPrompt, 30)
|
||||
userInput := s.buildSegUserInput(d, ep, segIdx, feedback, prevSummary)
|
||||
|
||||
result, err := reactAgent.Run(ctx, userInput)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *dramaService) buildSegPrompt(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur int, prevLastFrame string) string {
|
||||
frameLink := ""
|
||||
if prevLastFrame != "" && segIdx > 0 {
|
||||
frameLink = "\n【帧链接】上一段尾帧已作为本段首帧,保持视觉延续。"
|
||||
}
|
||||
return fmt.Sprintf(`%s
|
||||
|
||||
【分段生成】
|
||||
当前第%d集第%d段,本段%d秒。%s
|
||||
所有场景时长之和必须等于%d秒。`,
|
||||
PromptService.GetSystemPrompt(ctx),
|
||||
ep.Index, segIdx+1, segDur,
|
||||
frameLink, segDur,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, segIdx int, feedback string, prevSummary string) string {
|
||||
feedbackText := ""
|
||||
if feedback != "" {
|
||||
feedbackText = fmt.Sprintf("\n【用户反馈】\n%s\n请根据以上反馈调整本段内容。", feedback)
|
||||
}
|
||||
prevText := ""
|
||||
if prevSummary != "" {
|
||||
prevText = fmt.Sprintf("\n【前文摘要】\n%s", prevSummary)
|
||||
}
|
||||
return fmt.Sprintf(`【短剧信息】
|
||||
标题:%s
|
||||
风格:%s
|
||||
|
||||
【当前剧集】
|
||||
第%d集:%s
|
||||
剧本:%s%s%s`,
|
||||
d.Title, d.Style,
|
||||
ep.Index, ep.Title, ep.Script,
|
||||
prevText, feedbackText,
|
||||
)
|
||||
}
|
||||
|
||||
// ContinueSegment 确认当前段并继续下一段
|
||||
func (s *dramaService) ContinueSegment(ctx context.Context, taskId int64) error {
|
||||
task, err := dao.GenerationTask.GetOne(ctx, taskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task == nil {
|
||||
return fmt.Errorf("任务不存在: %d", taskId)
|
||||
}
|
||||
if task.Status != "review" {
|
||||
return fmt.Errorf("任务状态不是 review,无法继续")
|
||||
}
|
||||
|
||||
modelCfg := ConfigService.Get(ctx)
|
||||
|
||||
var sd model.StepsData
|
||||
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
|
||||
return fmt.Errorf("解析 steps_data 失败: %w", err)
|
||||
}
|
||||
|
||||
if sd.CurrentSegment < len(sd.Segments) {
|
||||
sd.Segments[sd.CurrentSegment].Confirmed = true
|
||||
}
|
||||
|
||||
nextSeg := sd.CurrentSegment + 1
|
||||
if nextSeg >= len(sd.Segments) {
|
||||
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, nextSeg, task.TotalSteps, task.StepsData, "merging")
|
||||
g.Log().Infof(ctx, "第%d集所有段落确认完成,开始合成视频", task.Id)
|
||||
go s.mergeEpisodeVideo(ctx, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
stepsDataBytes, _ := json.Marshal(sd)
|
||||
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, nextSeg, task.TotalSteps, string(stepsDataBytes), "generating")
|
||||
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "generating", "")
|
||||
|
||||
ep, err := dao.Episode.GetOne(ctx, task.EpisodeId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ep == nil {
|
||||
return fmt.Errorf("剧集不存在")
|
||||
}
|
||||
|
||||
d, err := dao.Drama.GetOne(ctx, task.DramaId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d == nil {
|
||||
return fmt.Errorf("短剧不存在")
|
||||
}
|
||||
|
||||
go func() {
|
||||
genCtx := context.Background()
|
||||
genCtx = agent.WithModelConfig(genCtx, &agent.ModelConfig{
|
||||
APIKey: modelCfg.ChatApiKey,
|
||||
BaseURL: modelCfg.ChatBaseURL,
|
||||
})
|
||||
if err := s.generateOneSegment(genCtx, d, ep, taskId, nextSeg, sd.LastFrame, ""); err != nil {
|
||||
g.Log().Errorf(genCtx, "第%d集第%d段生成失败: %v", ep.Index, nextSeg+1, err)
|
||||
_ = dao.GenerationTask.UpdateFailed(ctx, taskId, err.Error())
|
||||
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "failed", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FeedbackSegment 反馈并重新生成本段
|
||||
func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedback string) error {
|
||||
task, err := dao.GenerationTask.GetOne(ctx, taskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task == nil {
|
||||
return fmt.Errorf("任务不存在: %d", taskId)
|
||||
}
|
||||
if task.Status != "review" {
|
||||
return fmt.Errorf("任务状态不是 review,无法反馈")
|
||||
}
|
||||
|
||||
var sd model.StepsData
|
||||
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
|
||||
return fmt.Errorf("解析 steps_data 失败: %w", err)
|
||||
}
|
||||
|
||||
if sd.CurrentSegment < len(sd.Segments) {
|
||||
sd.Segments[sd.CurrentSegment].Feedback = feedback
|
||||
}
|
||||
|
||||
stepsDataBytes, _ := json.Marshal(sd)
|
||||
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, sd.CurrentSegment, task.TotalSteps, string(stepsDataBytes), "generating")
|
||||
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "generating", "")
|
||||
|
||||
d, err := dao.Drama.GetOne(ctx, task.DramaId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d == nil {
|
||||
return fmt.Errorf("短剧不存在")
|
||||
}
|
||||
ep, err := dao.Episode.GetOne(ctx, task.EpisodeId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ep == nil {
|
||||
return fmt.Errorf("剧集不存在")
|
||||
}
|
||||
|
||||
modelCfg := ConfigService.Get(ctx)
|
||||
|
||||
go func() {
|
||||
genCtx := context.Background()
|
||||
genCtx = agent.WithModelConfig(genCtx, &agent.ModelConfig{
|
||||
APIKey: modelCfg.ChatApiKey,
|
||||
BaseURL: modelCfg.ChatBaseURL,
|
||||
})
|
||||
if err := s.generateOneSegment(genCtx, d, ep, taskId, sd.CurrentSegment, sd.LastFrame, feedback); err != nil {
|
||||
g.Log().Errorf(genCtx, "第%d集第%d段重新生成失败: %v", ep.Index, sd.CurrentSegment+1, err)
|
||||
_ = dao.GenerationTask.UpdateFailed(ctx, taskId, err.Error())
|
||||
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "failed", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEpisodeTask 获取某集当前的活跃任务
|
||||
func (s *dramaService) GetEpisodeTask(ctx context.Context, epId int64) (*entity.GenerationTask, error) {
|
||||
return dao.GenerationTask.GetByEpisode(ctx, epId)
|
||||
}
|
||||
|
||||
// ==================== Video Generation ====================
|
||||
|
||||
// submitVideoTask 提交视频合成任务,返回 task_id(不轮询等待)
|
||||
func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx int, output *model.SegmentOutput) (string, error) {
|
||||
modelCfg := ConfigService.Get(ctx)
|
||||
if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseURL == "" || modelCfg.VideoModelName == "" {
|
||||
return "", fmt.Errorf("视频模型未配置")
|
||||
}
|
||||
|
||||
scenes := output.Scenes
|
||||
if len(scenes) == 0 {
|
||||
return "", fmt.Errorf("没有场景数据")
|
||||
}
|
||||
|
||||
sceneDescs := make([]string, 0, len(scenes))
|
||||
for _, s := range scenes {
|
||||
sceneDescs = append(sceneDescs, s.Description)
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf("短剧《%s》第%d集第%d段:%s", d.Title, ep.Index, segIdx+1, strings.Join(sceneDescs, ";"))
|
||||
|
||||
body := map[string]any{
|
||||
"model": modelCfg.VideoModelName,
|
||||
"input": map[string]any{
|
||||
"prompt": prompt,
|
||||
},
|
||||
"parameters": map[string]any{
|
||||
"duration": 5,
|
||||
"size": "1280*720",
|
||||
},
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", modelCfg.VideoBaseURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-DashScope-Async", "enable")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Output struct {
|
||||
TaskId string `json:"task_id"`
|
||||
} `json:"output"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", fmt.Errorf("解析视频合成响应失败: %s", string(data))
|
||||
}
|
||||
if result.Code != "" {
|
||||
return "", fmt.Errorf("视频合成请求失败: %s", string(data))
|
||||
}
|
||||
|
||||
taskId := result.Output.TaskId
|
||||
if taskId == "" {
|
||||
return "", fmt.Errorf("视频合成任务ID为空")
|
||||
}
|
||||
|
||||
return taskId, nil
|
||||
}
|
||||
|
||||
// pollVideoTaskOnce 单次查询视频任务状态(不循环等待)
|
||||
func (s *dramaService) pollVideoTaskOnce(ctx context.Context, modelCfg *model.ModelConfig, taskId string) (string, error) {
|
||||
queryURL := strings.ReplaceAll(modelCfg.VideoQueryURL, "{task_id}", taskId)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Output struct {
|
||||
TaskStatus string `json:"task_status"`
|
||||
VideoUrl string `json:"video_url"`
|
||||
} `json:"output"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", fmt.Errorf("解析视频任务状态失败: %s", string(data))
|
||||
}
|
||||
|
||||
if !g.IsEmpty(result.Output.VideoUrl) {
|
||||
return result.Output.VideoUrl, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// StartVideoPoller 启动后台视频轮询器(每 15 秒扫描一次)
|
||||
func (s *dramaService) StartVideoPoller(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
g.Log().Infof(ctx, "后台视频轮询器已启动,间隔 15s")
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.pollPendingVideos(context.Background())
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// pollPendingVideos 扫描 generating 状态的任务,对未完成的视频任务进行单次轮询
|
||||
func (s *dramaService) pollPendingVideos(ctx context.Context) {
|
||||
modelCfg := ConfigService.Get(ctx)
|
||||
if modelCfg.VideoApiKey == "" || modelCfg.VideoQueryURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := dao.GenerationTask.ListByStatus(ctx, "generating")
|
||||
if err != nil || len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
var sd model.StepsData
|
||||
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
|
||||
g.Log().Warningf(ctx, "轮询器: 任务 %d StepsData 解析失败: %v", task.Id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
updated := false
|
||||
for i := range sd.Segments {
|
||||
seg := &sd.Segments[i]
|
||||
if seg.VideoTaskId == "" || seg.VideoURL != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
videoURL, err := s.pollVideoTaskOnce(ctx, modelCfg, seg.VideoTaskId)
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "任务 %d 第%d段视频未就绪: %v", task.Id, i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
seg.VideoURL = videoURL
|
||||
seg.VideoTaskId = ""
|
||||
g.Log().Infof(ctx, "任务 %d 第%d段视频生成完成: %s", task.Id, i+1, videoURL)
|
||||
updated = true
|
||||
}
|
||||
|
||||
if !updated {
|
||||
continue
|
||||
}
|
||||
|
||||
stepsDataBytes, _ := json.Marshal(sd)
|
||||
_ = dao.GenerationTask.UpdateStepsData(ctx, task.Id, string(stepsDataBytes))
|
||||
_ = dao.GenerationTask.UpdateReview(ctx, task.Id, sd.CurrentSegment, string(stepsDataBytes))
|
||||
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "review", "")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
// calcSegmentDurations 将总时长按最大/最小段长拆分为多段
|
||||
func calcSegmentDurations(totalDuration, maxSingle, minSingle int) []int {
|
||||
if totalDuration <= maxSingle {
|
||||
return []int{totalDuration}
|
||||
}
|
||||
|
||||
numSegments := int(math.Ceil(float64(totalDuration) / float64(maxSingle)))
|
||||
if numSegments <= 0 {
|
||||
numSegments = 1
|
||||
}
|
||||
|
||||
base := totalDuration / numSegments
|
||||
remainder := totalDuration % numSegments
|
||||
|
||||
durations := make([]int, numSegments)
|
||||
for i := 0; i < numSegments; i++ {
|
||||
durations[i] = base
|
||||
if i < remainder {
|
||||
durations[i]++
|
||||
}
|
||||
}
|
||||
|
||||
for i := range durations {
|
||||
if durations[i] < minSingle && i > 0 {
|
||||
borrow := minSingle - durations[i]
|
||||
if durations[i-1]-borrow >= minSingle {
|
||||
durations[i-1] -= borrow
|
||||
durations[i] += borrow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return durations
|
||||
}
|
||||
|
||||
// mergeEpisodeVideo 将确认完成的各段视频下载并合并为最终视频
|
||||
func (s *dramaService) mergeEpisodeVideo(ctx context.Context, task *entity.GenerationTask) {
|
||||
var sd model.StepsData
|
||||
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
|
||||
g.Log().Errorf(ctx, "解析 steps_data 失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
outputDir := filepath.Join("output", fmt.Sprintf("ep_%d", task.EpisodeId))
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
g.Log().Errorf(ctx, "创建输出目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
segFiles := make([]string, 0, len(sd.Segments))
|
||||
for i, seg := range sd.Segments {
|
||||
if seg.VideoURL == "" {
|
||||
continue
|
||||
}
|
||||
localPath := filepath.Join(outputDir, fmt.Sprintf("seg_%d.mp4", i))
|
||||
if err := s.downloadFile(ctx, seg.VideoURL, localPath); err != nil {
|
||||
g.Log().Errorf(ctx, "下载第%d段视频失败: %v", i, err)
|
||||
return
|
||||
}
|
||||
segFiles = append(segFiles, localPath)
|
||||
}
|
||||
|
||||
if len(segFiles) == 0 {
|
||||
g.Log().Errorf(ctx, "没有可合并的视频文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 取尾帧
|
||||
lastSegFile := segFiles[len(segFiles)-1]
|
||||
lastFramePath := filepath.Join(outputDir, "last_frame.jpg")
|
||||
if err := s.extractLastFrame(lastSegFile, lastFramePath); err != nil {
|
||||
g.Log().Warningf(ctx, "提取尾帧失败: %v", err)
|
||||
}
|
||||
|
||||
if len(segFiles) == 1 {
|
||||
finalPath := filepath.Join(outputDir, "final.mp4")
|
||||
if err := s.copyFile(lastSegFile, finalPath); err != nil {
|
||||
g.Log().Errorf(ctx, "复制视频文件失败: %v", err)
|
||||
return
|
||||
}
|
||||
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "completed", finalPath)
|
||||
_ = dao.GenerationTask.UpdateProgress(ctx, task.Id, len(sd.Segments), task.TotalSteps, task.StepsData, "completed")
|
||||
g.Log().Infof(ctx, "单段视频已完成: %s", finalPath)
|
||||
return
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(outputDir, "final.mp4")
|
||||
if err := s.concatVideos(segFiles, finalPath); err != nil {
|
||||
g.Log().Errorf(ctx, "合并视频失败: %v", err)
|
||||
_ = dao.GenerationTask.UpdateFailed(ctx, task.Id, "视频合并失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "completed", finalPath)
|
||||
_ = dao.GenerationTask.UpdateProgress(ctx, task.Id, len(sd.Segments), task.TotalSteps, task.StepsData, "completed")
|
||||
g.Log().Infof(ctx, "视频合并完成: %s", finalPath)
|
||||
}
|
||||
|
||||
// extractLastFrame 使用 ffmpeg 提取视频尾帧
|
||||
func (s *dramaService) extractLastFrame(videoPath, outputPath string) error {
|
||||
cmd := exec.Command("ffmpeg", "-y", "-sseof", "-1", "-i", videoPath,
|
||||
"-frames:v", "1", "-q:v", "2", outputPath)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// concatVideos 使用 ffmpeg 合并多个视频
|
||||
func (s *dramaService) concatVideos(inputs []string, output string) error {
|
||||
// 生成 concat 文件列表
|
||||
listPath := output + ".list"
|
||||
f, err := os.Create(listPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range inputs {
|
||||
_, _ = f.WriteString(fmt.Sprintf("file '%s'\n", p))
|
||||
}
|
||||
_ = f.Close()
|
||||
defer os.Remove(listPath)
|
||||
|
||||
cmd := exec.Command("ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", listPath, "-c", "copy", output)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// downloadFile 下载文件到本地
|
||||
func (s *dramaService) downloadFile(ctx context.Context, url, dest string) error {
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// copyFile 复制文件
|
||||
func (s *dramaService) copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"video-factory/shortdrama/dao"
|
||||
"video-factory/shortdrama/model"
|
||||
"video-factory/shortdrama/model/entity"
|
||||
)
|
||||
|
||||
type promptService struct{}
|
||||
|
||||
var PromptService = new(promptService)
|
||||
|
||||
const PromptNameSystem = "system_prompt"
|
||||
|
||||
// GetSystemPrompt 获取系统提示词
|
||||
func (s *promptService) GetSystemPrompt(ctx context.Context) string {
|
||||
_ = dao.Prompt.CreateTable(ctx)
|
||||
|
||||
p, err := dao.Prompt.GetByName(ctx, PromptNameSystem)
|
||||
if err == nil && p != nil && p.Content != "" {
|
||||
return p.Content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetList 获取所有提示词
|
||||
func (s *promptService) GetList(ctx context.Context) []*model.Prompt {
|
||||
_ = dao.Prompt.CreateTable(ctx)
|
||||
|
||||
list, err := dao.Prompt.List(ctx)
|
||||
if err != nil || len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
res := make([]*model.Prompt, 0, len(list))
|
||||
for _, e := range list {
|
||||
res = append(res, s.entityToModel(e))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取提示词
|
||||
func (s *promptService) GetByName(ctx context.Context, name string) *model.Prompt {
|
||||
_ = dao.Prompt.CreateTable(ctx)
|
||||
|
||||
p, err := dao.Prompt.GetByName(ctx, name)
|
||||
if err != nil || p == nil {
|
||||
return nil
|
||||
}
|
||||
return s.entityToModel(p)
|
||||
}
|
||||
|
||||
// Save 保存提示词
|
||||
func (s *promptService) Save(ctx context.Context, cfg *model.Prompt) error {
|
||||
_ = dao.Prompt.CreateTable(ctx)
|
||||
return dao.Prompt.Save(ctx, s.modelToEntity(cfg))
|
||||
}
|
||||
|
||||
const initialSystemPrompt = `你是一位专业的短剧导演和编剧,负责将用户提交的剧本转化为结构化的短剧制作方案。
|
||||
|
||||
## 你的能力
|
||||
你拥有以下工具可以使用:
|
||||
|
||||
1. **parse_script**:将原始剧本文本解析为结构化剧集列表
|
||||
- 输入:原始剧本(多集内容用 --- 分隔)
|
||||
- 输出:JSON格式的剧集列表(包含每集的索引、标题和剧本内容)
|
||||
|
||||
2. **analyze_script_for_episode**:分析单集剧本,拆分为场景
|
||||
- 输入:单集剧本内容、本集时长、演员列表
|
||||
- 输出:JSON格式的场景列表(包含每个场景的描述、台词、时长、出场演员、画面描述)
|
||||
|
||||
3. **generate_character_image**:根据演员描述生成人物形象图
|
||||
- 输入:演员名称、演员详细描述、风格
|
||||
- 输出:包含图片base64编码的JSON
|
||||
|
||||
4. **generate_scene_image**:根据场景画面描述生成场景图
|
||||
- 输入:剧集索引、场景索引、画面描述、风格
|
||||
- 输出:包含图片base64编码的JSON
|
||||
|
||||
## 工作流程
|
||||
你必须按照以下步骤有序执行:
|
||||
|
||||
### 第一步:解析剧本
|
||||
使用 parse_script 工具解析原始剧本,获取结构化剧集列表。
|
||||
|
||||
### 第二步:逐集分析
|
||||
对于每一集剧本:
|
||||
1. 分析剧本内容,理解故事情节
|
||||
2. 使用 analyze_script_for_episode 将本集拆分为多个场景
|
||||
3. 合理安排场景时长,确保总时长符合用户要求
|
||||
4. 识别每个场景的出场演员
|
||||
|
||||
### 第三步:生成人物形象
|
||||
为剧本中的每个主要演员,调用 generate_character_image 生成人物形象图。
|
||||
|
||||
### 第四步:生成场景画面
|
||||
为每个场景,调用 generate_scene_image 生成对应的场景画面。
|
||||
|
||||
### 第五步:输出最终方案
|
||||
综合所有信息,输出完整的短剧制作方案。
|
||||
|
||||
## 重要规则
|
||||
1. 严格按照工作流程执行,不要跳过任何步骤
|
||||
2. 每个工具调用后,仔细分析返回结果再决定下一步
|
||||
3. 所有输出必须使用中文
|
||||
4. 最终输出必须包含完整的剧集结构、场景划分、演员信息和图片数据
|
||||
5. 合理安排每个场景的时长,确保总时长等于用户指定的每集时长
|
||||
6. 如果用户没有指定风格,默认使用"现代都市"风格
|
||||
|
||||
## 输出格式要求
|
||||
在完成所有步骤后,输出格式必须是一个JSON对象,包含以下字段:
|
||||
- title: 短剧标题
|
||||
- total_episodes: 总集数
|
||||
- total_duration: 总时长(秒)
|
||||
- episodes: 剧集列表,每集包含:
|
||||
- index: 集号
|
||||
- title: 本集标题
|
||||
- duration: 本集时长
|
||||
- scenes: 场景列表,每个场景包含:
|
||||
- index: 场景序号
|
||||
- description: 场景描述
|
||||
- lines: 台词/对白
|
||||
- duration: 场景时长(秒)
|
||||
- characters: 出场演员名列表
|
||||
- visualDesc: 画面描述
|
||||
- imageUrl: 场景图片(base64编码)
|
||||
- characters: 演员列表,每个演员包含:
|
||||
- name: 演员名
|
||||
- description: 演员描述
|
||||
- imageUrl: 演员形象图片(base64编码)
|
||||
`
|
||||
|
||||
// Init 初始化提示词表并插入初始数据
|
||||
func (s *promptService) Init(ctx context.Context) error {
|
||||
_ = dao.Prompt.CreateTable(ctx)
|
||||
|
||||
existing, err := dao.Prompt.GetByName(ctx, PromptNameSystem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil
|
||||
}
|
||||
return dao.Prompt.Save(ctx, &entity.Prompt{
|
||||
Name: PromptNameSystem,
|
||||
Content: initialSystemPrompt,
|
||||
Remark: "短剧生成系统的 ReAct Agent 系统提示词",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *promptService) entityToModel(e *entity.Prompt) *model.Prompt {
|
||||
return &model.Prompt{
|
||||
Name: e.Name,
|
||||
Content: e.Content,
|
||||
Remark: e.Remark,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *promptService) modelToEntity(cfg *model.Prompt) *entity.Prompt {
|
||||
return &entity.Prompt{
|
||||
Name: cfg.Name,
|
||||
Content: cfg.Content,
|
||||
Remark: cfg.Remark,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>短剧管理</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:#f0f2f5; color:#333; padding:20px; }
|
||||
.container { max-width:1000px; margin:0 auto; }
|
||||
.header { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; }
|
||||
.header h1 { font-size:24px; color:#1a1a2e; }
|
||||
.header-actions { display:flex; gap:10px; }
|
||||
.btn { display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; text-decoration:none; border:none; transition:all .2s; }
|
||||
.btn-primary { background:#4a6cf7; color:#fff; }
|
||||
.btn-primary:hover { background:#3a5ce5; }
|
||||
.btn-success { background:#e8f5e9; color:#2e7d32; }
|
||||
.btn-success:hover { background:#c8e6c9; }
|
||||
.btn-danger { background:#fee; color:#e44; }
|
||||
.btn-danger:hover { background:#fdd; }
|
||||
.btn-sm { padding:4px 10px; font-size:11px; }
|
||||
.btn-outline { background:transparent; border:1px solid #d9d9d9; color:#555; }
|
||||
.btn-outline:hover { border-color:#4a6cf7; color:#4a6cf7; }
|
||||
.btn:disabled { opacity:0.5; cursor:not-allowed; }
|
||||
.card { background:#fff; border-radius:12px; padding:20px; margin-bottom:16px; box-shadow:0 2px 8px rgba(0,0,0,0.06); display:flex; justify-content:space-between; align-items:center; }
|
||||
.card.clickable { cursor:pointer; transition:box-shadow .2s, transform .2s; }
|
||||
.card.clickable:hover { box-shadow:0 4px 16px rgba(0,0,0,0.1); transform:translateY(-1px); }
|
||||
.card-info h3 { font-size:16px; color:#1a1a2e; margin-bottom:6px; }
|
||||
.card-info .meta { font-size:13px; color:#888; display:flex; gap:16px; flex-wrap:wrap; }
|
||||
.card-info .meta span { display:inline-flex; align-items:center; gap:4px; }
|
||||
.card-actions { display:flex; gap:8px; flex-shrink:0; }
|
||||
.tag { display:inline-block; padding:2px 10px; border-radius:20px; font-size:11px; font-weight:600; }
|
||||
.tag-style { background:#eef1ff; color:#4a6cf7; }
|
||||
.empty { text-align:center; padding:60px 20px; color:#999; }
|
||||
.empty p { font-size:14px; margin-bottom:16px; }
|
||||
|
||||
/* 弹窗通用 */
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4); z-index:1000; justify-content:center; align-items:center; }
|
||||
#confirmModal { z-index:1100; }
|
||||
.modal-overlay.show { display:flex; }
|
||||
.modal { background:#fff; border-radius:12px; padding:24px; max-width:400px; width:90%; box-shadow:0 20px 60px rgba(0,0,0,0.2); max-height:90vh; overflow-y:auto; }
|
||||
.modal-wide { max-width:480px; }
|
||||
.modal h3 { font-size:16px; margin-bottom:16px; }
|
||||
.modal h4 { font-size:14px; margin-bottom:10px; color:#1a1a2e; }
|
||||
.modal p { font-size:14px; color:#666; margin-bottom:20px; }
|
||||
.modal-actions { display:flex; gap:10px; justify-content:flex-end; }
|
||||
.form-group { margin-bottom:14px; }
|
||||
.form-group:last-child { margin-bottom:0; }
|
||||
.form-group label { display:block; font-size:12px; font-weight:600; color:#555; margin-bottom:4px; }
|
||||
.form-group input, .form-group select { width:100%; padding:8px 10px; border:1px solid #d9d9d9; border-radius:6px; font-size:13px; outline:none; }
|
||||
.form-group input:focus, .form-group select:focus { border-color:#4a6cf7; box-shadow:0 0 0 2px rgba(74,108,247,0.1); }
|
||||
.row { display:flex; gap:12px; }
|
||||
.row > * { flex:1; }
|
||||
.help-text { font-size:12px; color:#999; margin-top:4px; }
|
||||
.success-msg { display:none; text-align:center; padding:8px; background:#e8f5e9; border-radius:6px; color:#2e7d32; font-size:12px; margin-top:10px; }
|
||||
.success-msg.show { display:block; }
|
||||
.spinner { width:18px; height:18px; border:3px solid #e0e0e0; border-top-color:#4a6cf7; border-radius:50%; animation:spin .6s linear infinite; display:inline-block; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
.add-link { display:inline-flex; align-items:center; gap:4px; color:#4a6cf7; font-size:13px; cursor:pointer; margin-top:6px; }
|
||||
.add-link:hover { text-decoration:underline; }
|
||||
|
||||
/* 剧集 */
|
||||
.ep-list { display:grid; gap:10px; }
|
||||
.ep-item { display:flex; gap:12px; align-items:flex-start; padding:14px 16px; background:#fafafa; border-radius:8px; border:1px solid #eee; }
|
||||
.ep-item .index { width:28px; height:28px; border-radius:50%; background:#4a6cf7; color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:600; flex-shrink:0; }
|
||||
.ep-item .info { flex:1; }
|
||||
.ep-item .info .title { font-size:14px; font-weight:600; }
|
||||
.ep-item .info .script { font-size:12px; color:#888; margin-top:4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||
.ep-item .info .script.expanded { -webkit-line-clamp:unset; }
|
||||
.ep-item .info .script-preview { font-size:12px; color:#888; margin-top:4px; cursor:pointer; }
|
||||
.ep-item .info .script-preview:hover { color:#4a6cf7; }
|
||||
.ep-item .info .status { font-size:11px; margin-top:4px; display:inline-flex; align-items:center; gap:4px; }
|
||||
.ep-item .info .status.pending { color:#999; }
|
||||
.ep-item .info .status.generating { color:#f90; }
|
||||
.ep-item .info .status.completed { color:#090; }
|
||||
.ep-item .info .status.failed { color:#e44; }
|
||||
.ep-item .actions { display:flex; gap:6px; flex-shrink:0; flex-wrap:wrap; }
|
||||
@media (max-width:640px) { .ep-item { flex-direction:column; } .ep-item .actions { align-self:flex-end; } }
|
||||
|
||||
/* 预览审核 */
|
||||
.spinner-sm { display:inline-block; width:12px; height:12px; border:2px solid #f90; border-top-color:transparent; border-radius:50%; animation:spin .6s linear infinite; margin-right:4px; vertical-align:middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>短剧管理</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-outline" onclick="showConfigModal()">模型配置</button>
|
||||
<button class="btn btn-outline" onclick="showPromptModal()">提示词配置</button>
|
||||
<button class="btn btn-primary" onclick="showDramaModal()">+ 新建短剧</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dramaList"></div>
|
||||
<div class="empty" id="emptyState" style="display:none">
|
||||
<p>暂无短剧,点击上方按钮创建第一个短剧</p>
|
||||
<button class="btn btn-primary" onclick="showDramaModal()">+ 新建短剧</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<div class="modal-overlay" id="confirmModal">
|
||||
<div class="modal">
|
||||
<h3 id="confirmTitle">确认删除</h3>
|
||||
<p id="confirmMsg" style="font-size:14px;color:#666;margin-bottom:20px;">确定要删除这个短剧吗?此操作不可恢复。</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeModal()">取消</button>
|
||||
<button class="btn btn-danger" id="confirmBtn">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 短剧新增/编辑弹窗 -->
|
||||
<div class="modal-overlay" id="dramaModal">
|
||||
<div class="modal modal-wide">
|
||||
<h3 id="dramaModalTitle">新建短剧</h3>
|
||||
<div class="form-group">
|
||||
<label>短剧标题</label>
|
||||
<input type="text" id="modalTitle" placeholder="请输入短剧标题">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>风格</label>
|
||||
<select id="modalStyle">
|
||||
<option value="现代都市">现代都市</option>
|
||||
<option value="古装仙侠">古装仙侠</option>
|
||||
<option value="科幻未来">科幻未来</option>
|
||||
<option value="悬疑推理">悬疑推理</option>
|
||||
<option value="古装">古装</option>
|
||||
<option value="现代">现代</option>
|
||||
<option value="科幻">科幻</option>
|
||||
<option value="奇幻">奇幻</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>每集时长(秒)</label>
|
||||
<input type="number" id="modalEpisodeDuration" value="60" min="10" max="600">
|
||||
</div>
|
||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #eee;">
|
||||
<h4>演员管理 <span style="font-size:12px;color:#888;" id="modalCharCount">0</span></h4>
|
||||
<div id="modalCharList"></div>
|
||||
<div class="add-link" onclick="showModalCharForm()">+ 添加演员</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="hideDramaModal()">取消</button>
|
||||
<button class="btn btn-primary" id="saveDramaBtn" onclick="saveDrama()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 演员编辑弹窗 -->
|
||||
<div class="modal-overlay" id="modalCharModal">
|
||||
<div class="modal">
|
||||
<h3 id="modalCharTitle">添加演员</h3>
|
||||
<div class="form-group">
|
||||
<label>演员名</label>
|
||||
<input type="text" id="modalCharName" placeholder="请输入演员名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>声音类型</label>
|
||||
<input type="text" id="modalCharVoice" placeholder="如:男中音">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>演员描述</label>
|
||||
<input type="text" id="modalCharDesc" placeholder="请输入演员描述">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>形象图 URL</label>
|
||||
<input type="text" id="modalCharPortrait" placeholder="可选">
|
||||
</div>
|
||||
<input type="hidden" id="editModalCharIdx" value="-1">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="hideModalCharForm()">取消</button>
|
||||
<button class="btn btn-primary" onclick="saveModalChar()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 剧集管理弹窗 -->
|
||||
<div class="modal-overlay" id="episodeModal">
|
||||
<div class="modal" style="max-width:640px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<div>
|
||||
<h3 id="epModalDramaTitle" style="margin-bottom:4px;">加载中...</h3>
|
||||
<span id="epModalDramaMeta" style="font-size:12px;color:#888;"></span>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="hideEpisodeModal()">关闭</button>
|
||||
</div>
|
||||
<div id="epList" class="ep-list" style="max-height:50vh;overflow-y:auto;"></div>
|
||||
<div class="add-link" onclick="showEpModal()" style="margin-top:8px;">+ 添加剧集</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 剧集编辑弹窗 -->
|
||||
<div class="modal-overlay" id="epModal">
|
||||
<div class="modal modal-wide">
|
||||
<h3 id="epModalTitle">添加剧集</h3>
|
||||
<div class="form-group">
|
||||
<label>剧集标题</label>
|
||||
<input type="text" id="epTitle" placeholder="请输入剧集标题">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>集数序号</label>
|
||||
<input type="number" id="epIndex" placeholder="如:1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>剧本内容</label>
|
||||
<textarea id="epScript" placeholder="请输入剧本内容" rows="4" style="width:100%;padding:8px 10px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;outline:none;font-family:inherit;resize:vertical"></textarea>
|
||||
</div>
|
||||
<input type="hidden" id="editEpId" value="">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="hideEpModal()">取消</button>
|
||||
<button class="btn btn-primary" id="saveEpBtn" onclick="saveEp()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览审核弹窗 -->
|
||||
<div class="modal-overlay" id="reviewModal">
|
||||
<div class="modal" style="max-width:640px;">
|
||||
<h3 id="reviewModalTitle">预览审核</h3>
|
||||
<div id="reviewContent" style="max-height:60vh;overflow-y:auto;">
|
||||
<div id="reviewLoading" style="text-align:center;padding:40px;color:#888;">加载中...</div>
|
||||
<div id="reviewSegments" style="display:none;"></div>
|
||||
<div id="reviewBody" style="display:none;">
|
||||
<div class="form-group" style="margin-bottom:12px;">
|
||||
<label>反馈意见(可选)</label>
|
||||
<textarea id="feedbackInput" placeholder="如需修改,请描述具体意见..." rows="3" style="width:100%;padding:8px 10px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;outline:none;font-family:inherit;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button class="btn btn-primary" id="continueBtn" onclick="continueSegment()">继续下一段</button>
|
||||
<button class="btn btn-outline" id="feedbackBtn" onclick="feedbackSegment()">重新生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions" style="margin-top:12px;padding-top:12px;border-top:1px solid #eee;">
|
||||
<button class="btn btn-outline" onclick="hideReviewPanel()">关闭</button>
|
||||
<span id="reviewStatus" style="font-size:12px;color:#888;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let deleteId = null;
|
||||
let editDramaId = null;
|
||||
let modalCharacters = [];
|
||||
let modalRemovedCharIds = [];
|
||||
let confirmAction = null;
|
||||
let currentDramaId = null;
|
||||
let currentDrama = null;
|
||||
let pollingInterval = null;
|
||||
let currentPollEpId = null;
|
||||
let currentTaskId = null;
|
||||
|
||||
// ==================== 短剧列表 ====================
|
||||
|
||||
async function loadList() {
|
||||
try {
|
||||
const resp = await fetch('/drama/list');
|
||||
const data = await resp.json();
|
||||
if (data.code !== 0) throw new Error(data.message);
|
||||
renderList(data.data.list);
|
||||
} catch (err) {
|
||||
document.getElementById('dramaList').innerHTML = '<div class="card" style="color:red;cursor:default">加载失败: ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(list) {
|
||||
const container = document.getElementById('dramaList');
|
||||
const empty = document.getElementById('emptyState');
|
||||
if (!list || list.length === 0) {
|
||||
container.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
container.innerHTML = list.map(d => {
|
||||
const epCount = d.epCount || 0;
|
||||
const epDuration = d.episodeDuration || '-';
|
||||
return `<div class="card clickable" onclick="openEpisodeModal('${d.id}')">
|
||||
<div class="card-info">
|
||||
<h3>${escHtml(d.title)}</h3>
|
||||
<div class="meta">
|
||||
<span class="tag tag-style">${escHtml(d.style)}</span>
|
||||
<span>${epCount} 集</span>
|
||||
<span>每集 ${epDuration}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions" onclick="event.stopPropagation()">
|
||||
<button class="btn btn-sm btn-outline" onclick="openEpisodeModal('${d.id}')">剧集管理</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="editDrama('${d.id}')">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="confirmDelete('${d.id}','${escHtml(d.title)}')">删除</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ==================== 短剧 CRUD ====================
|
||||
|
||||
function showDramaModal() {
|
||||
editDramaId = null;
|
||||
document.getElementById('dramaModalTitle').textContent = '新建短剧';
|
||||
document.getElementById('modalTitle').value = '';
|
||||
document.getElementById('modalStyle').value = '现代都市';
|
||||
document.getElementById('modalEpisodeDuration').value = 60;
|
||||
modalCharacters = [];
|
||||
modalRemovedCharIds = [];
|
||||
renderModalCharList();
|
||||
document.getElementById('dramaModal').classList.add('show');
|
||||
}
|
||||
|
||||
function editDrama(id) {
|
||||
editDramaId = id;
|
||||
document.getElementById('dramaModalTitle').textContent = '编辑短剧';
|
||||
document.getElementById('dramaModal').classList.add('show');
|
||||
fetch('/drama/get?id=' + id).then(r => r.json()).then(data => {
|
||||
if (data.code !== 0) throw new Error(data.message);
|
||||
const d = data.data;
|
||||
document.getElementById('modalTitle').value = d.title || '';
|
||||
document.getElementById('modalStyle').value = d.style || '';
|
||||
document.getElementById('modalEpisodeDuration').value = d.episodeDuration || 60;
|
||||
modalCharacters = (d.characters || []).map(function(c) {
|
||||
return { id: c.id, name: c.name, description: c.description, voiceType: c.voiceType, portraitUrl: c.portraitUrl };
|
||||
});
|
||||
modalRemovedCharIds = [];
|
||||
renderModalCharList();
|
||||
}).catch(err => {
|
||||
alert('加载短剧信息失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function hideDramaModal() {
|
||||
document.getElementById('dramaModal').classList.remove('show');
|
||||
editDramaId = null;
|
||||
}
|
||||
|
||||
async function saveDrama() {
|
||||
const title = document.getElementById('modalTitle').value.trim();
|
||||
if (!title) { alert('请输入短剧标题'); return; }
|
||||
const body = {
|
||||
title: title,
|
||||
style: document.getElementById('modalStyle').value,
|
||||
episodeDuration: parseInt(document.getElementById('modalEpisodeDuration').value) || 60,
|
||||
};
|
||||
const btn = document.getElementById('saveDramaBtn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
let url;
|
||||
if (editDramaId) { url = '/drama/update'; body.id = parseInt(editDramaId); }
|
||||
else { url = '/drama/create'; }
|
||||
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await resp.json();
|
||||
if (data.code !== 0) throw new Error(data.message);
|
||||
const dramaId = editDramaId ? parseInt(editDramaId) : data.data.id;
|
||||
for (var i = 0; i < modalRemovedCharIds.length; i++) {
|
||||
await fetch('/drama/character/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dramaId, charId: modalRemovedCharIds[i] }) });
|
||||
}
|
||||
for (var i = 0; i < modalCharacters.length; i++) {
|
||||
var c = modalCharacters[i];
|
||||
var charBody = { dramaId, name: c.name, description: c.description || '', voiceType: c.voiceType || '', portraitUrl: c.portraitUrl || '' };
|
||||
if (c.id > 0) { charBody.charId = c.id; await fetch('/drama/character/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(charBody) }); }
|
||||
else { await fetch('/drama/character/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(charBody) }); }
|
||||
}
|
||||
hideDramaModal();
|
||||
loadList();
|
||||
} catch (err) {
|
||||
alert('保存失败: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 演员管理(弹窗内) ====================
|
||||
|
||||
function renderModalCharList() {
|
||||
const list = document.getElementById('modalCharList');
|
||||
document.getElementById('modalCharCount').textContent = modalCharacters.length;
|
||||
if (modalCharacters.length === 0) { list.innerHTML = '<div style="color:#999;font-size:12px;padding:4px 0">暂无演员</div>'; return; }
|
||||
list.innerHTML = modalCharacters.map(function(c, i) {
|
||||
var initial = c.name ? c.name.charAt(0) : '?';
|
||||
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 8px;background:#fafafa;border-radius:6px;border:1px solid #eee;margin-bottom:4px;">'
|
||||
+ '<div style="width:28px;height:28px;border-radius:50%;background:#eef1ff;display:flex;align-items:center;justify-content:center;font-size:12px;color:#4a6cf7;flex-shrink:0;">' + initial + '</div>'
|
||||
+ '<div style="flex:1;font-size:12px;"><strong>' + escHtml(c.name) + '</strong>' + (c.voiceType ? ' · ' + escHtml(c.voiceType) : '') + '</div>'
|
||||
+ '<div style="display:flex;gap:4px;flex-shrink:0;">'
|
||||
+ '<button class="btn btn-sm btn-outline" onclick="editModalChar(' + i + ')" style="padding:2px 8px;font-size:11px;">编辑</button>'
|
||||
+ '<button class="btn btn-sm btn-danger" onclick="removeModalChar(' + i + ')" style="padding:2px 8px;font-size:11px;">删除</button></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showModalCharForm() {
|
||||
document.getElementById('modalCharTitle').textContent = '添加演员';
|
||||
document.getElementById('editModalCharIdx').value = '-1';
|
||||
document.getElementById('modalCharName').value = ''; document.getElementById('modalCharDesc').value = '';
|
||||
document.getElementById('modalCharVoice').value = ''; document.getElementById('modalCharPortrait').value = '';
|
||||
document.getElementById('modalCharModal').classList.add('show');
|
||||
}
|
||||
|
||||
function hideModalCharForm() { document.getElementById('modalCharModal').classList.remove('show'); }
|
||||
|
||||
function editModalChar(idx) {
|
||||
var c = modalCharacters[idx]; if (!c) return;
|
||||
document.getElementById('modalCharTitle').textContent = '编辑演员';
|
||||
document.getElementById('editModalCharIdx').value = idx;
|
||||
document.getElementById('modalCharName').value = c.name || ''; document.getElementById('modalCharDesc').value = c.description || '';
|
||||
document.getElementById('modalCharVoice').value = c.voiceType || ''; document.getElementById('modalCharPortrait').value = c.portraitUrl || '';
|
||||
document.getElementById('modalCharModal').classList.add('show');
|
||||
}
|
||||
|
||||
function saveModalChar() {
|
||||
var name = document.getElementById('modalCharName').value.trim();
|
||||
if (!name) { alert('请输入演员名'); return; }
|
||||
var idx = parseInt(document.getElementById('editModalCharIdx').value);
|
||||
var charData = { name, description: document.getElementById('modalCharDesc').value.trim(), voiceType: document.getElementById('modalCharVoice').value.trim(), portraitUrl: document.getElementById('modalCharPortrait').value.trim() };
|
||||
if (idx >= 0) { Object.assign(modalCharacters[idx], charData); }
|
||||
else { charData.id = 0; modalCharacters.push(charData); }
|
||||
hideModalCharForm(); renderModalCharList();
|
||||
}
|
||||
|
||||
function removeModalChar(idx) {
|
||||
var c = modalCharacters[idx]; if (!c) return;
|
||||
confirmAction = function() {
|
||||
if (c.id > 0) modalRemovedCharIds.push(c.id);
|
||||
modalCharacters.splice(idx, 1); renderModalCharList();
|
||||
};
|
||||
document.getElementById('confirmTitle').textContent = '删除演员';
|
||||
document.getElementById('confirmMsg').textContent = '确定要删除演员「' + c.name + '」吗?';
|
||||
document.getElementById('confirmBtn').className = 'btn btn-danger';
|
||||
document.getElementById('confirmModal').classList.add('show');
|
||||
}
|
||||
|
||||
// ==================== 删除短剧 ====================
|
||||
|
||||
function confirmDelete(id, title) {
|
||||
deleteId = id;
|
||||
document.getElementById('confirmTitle').textContent = '确认删除';
|
||||
document.getElementById('confirmMsg').textContent = '确定要删除《' + title + '》吗?此操作不可恢复。';
|
||||
document.getElementById('confirmBtn').className = 'btn btn-danger';
|
||||
document.getElementById('confirmModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('confirmModal').classList.remove('show');
|
||||
deleteId = null;
|
||||
confirmAction = null;
|
||||
}
|
||||
|
||||
document.getElementById('confirmBtn').addEventListener('click', async function() {
|
||||
if (confirmAction) { await confirmAction(); closeModal(); return; }
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await fetch('/drama/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: parseInt(deleteId) }) });
|
||||
closeModal(); loadList();
|
||||
} catch (err) { alert('删除失败: ' + err.message); closeModal(); }
|
||||
});
|
||||
|
||||
// ==================== 剧集管理 ====================
|
||||
|
||||
function openEpisodeModal(id) {
|
||||
currentDramaId = id;
|
||||
document.getElementById('episodeModal').classList.add('show');
|
||||
document.getElementById('epModalDramaTitle').textContent = '加载中...';
|
||||
document.getElementById('epModalDramaMeta').textContent = '';
|
||||
loadEpisodeDrama(id);
|
||||
}
|
||||
|
||||
function hideEpisodeModal() {
|
||||
stopPolling();
|
||||
document.getElementById('episodeModal').classList.remove('show');
|
||||
currentDramaId = null;
|
||||
currentDrama = null;
|
||||
}
|
||||
|
||||
async function loadEpisodeDrama(id) {
|
||||
try {
|
||||
const resp = await fetch('/drama/get?id=' + id);
|
||||
const data = await resp.json();
|
||||
if (data.code !== 0) throw new Error(data.message);
|
||||
currentDrama = data.data;
|
||||
document.getElementById('epModalDramaTitle').textContent = currentDrama.title || '未命名短剧';
|
||||
document.getElementById('epModalDramaMeta').textContent = (currentDrama.style || '') + ' · ' + (currentDrama.episodeDuration || '-') + 's/集';
|
||||
renderEps();
|
||||
} catch (err) {
|
||||
document.getElementById('epModalDramaTitle').textContent = '加载失败';
|
||||
alert('加载剧集失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEps() {
|
||||
const list = document.getElementById('epList');
|
||||
const eps = currentDrama.episodes || [];
|
||||
if (eps.length === 0) {
|
||||
list.innerHTML = '<div style="color:#999;font-size:13px;padding:8px 0">暂无剧集,点击下方添加</div>';
|
||||
return;
|
||||
}
|
||||
var taskMap = {};
|
||||
if (currentDrama.tasks) { currentDrama.tasks.forEach(function(t) { taskMap[t.episodeId] = t; }); }
|
||||
eps.sort(function(a, b) { return (a.index || 0) - (b.index || 0); });
|
||||
list.innerHTML = eps.map(function(ep) {
|
||||
const status = ep.status || 'pending';
|
||||
const statusLabels = { pending:'待生成', generating:'生成中...', review:'待审核', completed:'已完成', failed:'生成失败' };
|
||||
const scriptPreview = ep.script ? (ep.script.length > 60 ? ep.script.substring(0,60) + '...' : ep.script) : '';
|
||||
var segHtml = '';
|
||||
if (status !== 'pending' && status !== 'failed') {
|
||||
var task = taskMap[ep.id];
|
||||
if (task && task.stepsData) { segHtml = segmentPreviews(task, ep.id); }
|
||||
}
|
||||
return '<div class="ep-item">'
|
||||
+ '<div class="index">' + (ep.index || '-') + '</div>'
|
||||
+ '<div class="info">'
|
||||
+ '<div class="title">' + escHtml(ep.title || '未命名') + '</div>'
|
||||
+ (scriptPreview ? '<div class="script-preview" onclick="toggleScript(this)" data-full="' + escAttr(ep.script) + '">' + escHtml(scriptPreview) + '</div>' : '')
|
||||
+ '<div class="status ' + status + '">'
|
||||
+ (status === 'generating' ? '<span class="spinner-sm"></span>' : '')
|
||||
+ (statusLabels[status] || status)
|
||||
+ (ep.videoUrl ? ' · <button class="btn btn-sm btn-outline" onclick="previewEpisode(\'' + ep.id + '\')">预览</button>' : '')
|
||||
+ segHtml
|
||||
+ '</div></div>'
|
||||
+ '<div class="actions">'
|
||||
+ (status === 'pending' || status === 'failed' ? '<button class="btn btn-sm btn-success" onclick="generateEp(\'' + ep.id + '\')">生成视频</button>' : '')
|
||||
+ (status === 'review' ? '<button class="btn btn-sm btn-primary" onclick="openReviewPanel(\'' + ep.id + '\')">审核</button>' : '')
|
||||
+ '<button class="btn btn-sm btn-outline" onclick="editEp(\'' + ep.id + '\')">编辑</button>'
|
||||
+ '<button class="btn btn-sm btn-danger" onclick="deleteEp(\'' + ep.id + '\',\'' + escAttr(ep.title) + '\')">删除</button>'
|
||||
+ '</div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function segmentPreviews(task, epId) {
|
||||
var sd; try { sd = JSON.parse(task.stepsData); } catch(e) { return ""; }
|
||||
var segs = sd.segments || [];
|
||||
// 只显示已有实际内容的片段(已生成的,不是空占位)
|
||||
var actualSegs = segs.filter(function(s) { return s.textOutput || (s.scenes && s.scenes.length > 0); });
|
||||
if (actualSegs.length === 0) return "";
|
||||
return '<span style="font-size:11px;color:#888;margin-left:6px;">(' + actualSegs.length + '段)</span>'
|
||||
+ ' <span style="display:inline-flex;gap:4px;margin-left:4px;flex-wrap:wrap;">'
|
||||
+ actualSegs.map(function(s, i) {
|
||||
var hasVideo = s.videoUrl ? true : false;
|
||||
var cls = hasVideo ? 'btn btn-sm btn-outline' : 'btn btn-sm btn-outline disabled';
|
||||
var click = hasVideo ? 'onclick="previewSegment(\'' + epId + '\',' + s.index + ')"' : '';
|
||||
return '<button class="' + cls + '" ' + click + ' style="font-size:11px;padding:1px 6px;">片段' + (s.index + 1) + '</button>';
|
||||
}).join('') + '</span>';
|
||||
}
|
||||
|
||||
function toggleScript(el) {
|
||||
if (el.classList.contains('expanded')) { el.classList.remove('expanded'); el.textContent = el.textContent.substring(0, 60) + '...'; }
|
||||
else { el.classList.add('expanded'); el.textContent = el.getAttribute('data-full') || el.textContent; }
|
||||
}
|
||||
|
||||
// ==================== 剧集 CRUD ====================
|
||||
|
||||
function showEpModal() {
|
||||
document.getElementById('epModalTitle').textContent = '添加剧集';
|
||||
document.getElementById('editEpId').value = '';
|
||||
document.getElementById('epTitle').value = ''; document.getElementById('epIndex').value = '';
|
||||
document.getElementById('epScript').value = '';
|
||||
document.getElementById('epModal').classList.add('show');
|
||||
}
|
||||
|
||||
function hideEpModal() { document.getElementById('epModal').classList.remove('show'); }
|
||||
|
||||
function editEp(epId) {
|
||||
const ep = (currentDrama.episodes || []).find(x => x.id == epId);
|
||||
if (!ep) return;
|
||||
document.getElementById('epModalTitle').textContent = '编辑剧集';
|
||||
document.getElementById('editEpId').value = epId;
|
||||
document.getElementById('epTitle').value = ep.title || '';
|
||||
document.getElementById('epIndex').value = ep.index || '';
|
||||
document.getElementById('epScript').value = ep.script || '';
|
||||
document.getElementById('epModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function saveEp() {
|
||||
const title = document.getElementById('epTitle').value.trim();
|
||||
const index = parseInt(document.getElementById('epIndex').value);
|
||||
if (!title) { alert('请输入剧集标题'); return; }
|
||||
if (isNaN(index)) { alert('请输入有效序号'); return; }
|
||||
const editEpId = document.getElementById('editEpId').value;
|
||||
const body = { dramaId: parseInt(currentDramaId), title, script: document.getElementById('epScript').value.trim(), index };
|
||||
if (editEpId) body.epId = parseInt(editEpId);
|
||||
const btn = document.getElementById('saveEpBtn'); btn.disabled = true;
|
||||
try {
|
||||
const url = editEpId ? '/drama/episode/update' : '/drama/episode/add';
|
||||
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await resp.json();
|
||||
if (data.code !== 0) throw new Error(data.message);
|
||||
currentDrama = data.data; hideEpModal(); renderEps();
|
||||
} catch (err) { alert('保存剧集失败: ' + err.message); }
|
||||
finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
function deleteEp(epId, title) {
|
||||
confirmAction = async function() {
|
||||
try {
|
||||
const resp = await fetch('/drama/episode/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dramaId: parseInt(currentDramaId), epId: parseInt(epId) }) });
|
||||
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
|
||||
currentDrama = data.data; renderEps();
|
||||
} catch (err) { alert('删除剧集失败: ' + err.message); }
|
||||
};
|
||||
document.getElementById('confirmTitle').textContent = '删除剧集';
|
||||
document.getElementById('confirmMsg').textContent = '确定要删除剧集「' + title + '」吗?';
|
||||
document.getElementById('confirmBtn').className = 'btn btn-danger';
|
||||
document.getElementById('confirmModal').classList.add('show');
|
||||
}
|
||||
|
||||
// ==================== 生成与轮询 ====================
|
||||
|
||||
async function generateEp(epId) {
|
||||
const btn = event.target; btn.disabled = true; btn.textContent = '生成中...';
|
||||
try {
|
||||
const resp = await fetch('/drama/episode/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dramaId: parseInt(currentDramaId), epId: parseInt(epId) }) });
|
||||
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
|
||||
startPolling(epId);
|
||||
} catch (err) { alert('生成失败: ' + err.message); btn.disabled = false; btn.textContent = '生成视频'; }
|
||||
}
|
||||
|
||||
function startPolling(epId) {
|
||||
currentPollEpId = epId; pollEpisode();
|
||||
pollingInterval = setInterval(pollEpisode, 2000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; }
|
||||
currentPollEpId = null;
|
||||
}
|
||||
|
||||
async function pollEpisode() {
|
||||
if (!currentPollEpId) return;
|
||||
try {
|
||||
const resp = await fetch('/drama/get?id=' + currentDramaId);
|
||||
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
|
||||
currentDrama = data.data; renderEps();
|
||||
const ep = (currentDrama.episodes || []).find(x => x.id == currentPollEpId);
|
||||
if (!ep) { stopPolling(); return; }
|
||||
if (ep.status === 'review') {
|
||||
const taskResp = await fetch('/drama/episode/task?epId=' + currentPollEpId);
|
||||
const taskData = await taskResp.json();
|
||||
if (taskData.code === 0 && taskData.data && taskData.data.task) showReviewPanel(taskData.data.task);
|
||||
} else if (ep.status === 'completed') { stopPolling(); showReviewPanel(null); loadEpisodeDrama(currentDramaId); }
|
||||
else if (ep.status === 'failed') { stopPolling(); showReviewPanel(null); alert('生成失败: ' + (ep.errorMessage || '未知错误')); loadEpisodeDrama(currentDramaId); }
|
||||
} catch (err) { console.error('轮询失败:', err); }
|
||||
}
|
||||
|
||||
// ==================== 预览审核 ====================
|
||||
|
||||
function showReviewPanel(task) {
|
||||
const modal = document.getElementById('reviewModal');
|
||||
if (!task) { modal.classList.remove('show'); return; }
|
||||
document.getElementById('reviewLoading').style.display = 'none';
|
||||
document.getElementById('reviewSegments').style.display = 'block';
|
||||
document.getElementById('reviewBody').style.display = 'block';
|
||||
modal.classList.add('show');
|
||||
document.getElementById('reviewModalTitle').textContent = '预览审核';
|
||||
currentTaskId = task.id;
|
||||
var stepsData = null; try { stepsData = JSON.parse(task.stepsData || '{}'); } catch(e) { stepsData = {}; }
|
||||
var segments = stepsData.segments || [];
|
||||
document.getElementById('reviewSegments').innerHTML = segments.map(function(seg, i) {
|
||||
return '<div style="margin-bottom:16px;border-bottom:1px solid #eee;padding-bottom:16px;">'
|
||||
+ '<div style="font-size:13px;font-weight:600;color:#555;margin-bottom:8px;">第' + (i+1) + '段</div>'
|
||||
+ (seg.videoUrl ? '<video src="' + seg.videoUrl + '" controls style="max-width:100%;max-height:400px;border-radius:8px;"></video>' : '<div style="color:#888;font-size:13px;">无视频</div>')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
document.getElementById('feedbackInput').value = '';
|
||||
document.getElementById('reviewStatus').textContent = '等待审核...';
|
||||
}
|
||||
|
||||
function hideReviewPanel() { document.getElementById('reviewModal').classList.remove('show'); }
|
||||
|
||||
function openReviewPanel(epId) {
|
||||
fetch('/drama/episode/task?epId=' + epId).then(r => r.json()).then(d => {
|
||||
if (d.code !== 0) { alert(d.message); return; }
|
||||
showReviewPanel(d.data.task);
|
||||
}).catch(err => alert('加载审核数据失败: ' + err.message));
|
||||
}
|
||||
|
||||
function previewEpisode(epId, segIdx) {
|
||||
segIdx = segIdx !== undefined ? segIdx : -1;
|
||||
fetch('/drama/episode/task?epId=' + epId).then(r => r.json()).then(d => {
|
||||
if (d.code !== 0) { alert(d.message); return; }
|
||||
var task = d.data.task; var sd; try { sd = JSON.parse(task.stepsData || '{}'); } catch(e) { sd = {}; }
|
||||
var segments = sd.segments || [];
|
||||
var html = '';
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i]; var isFocused = (segIdx >= 0 && i === segIdx);
|
||||
var style = isFocused ? 'margin-bottom:16px;border:2px solid #4a6cf7;border-radius:8px;padding:16px;background:#f8f9ff;' : 'margin-bottom:16px;border-bottom:1px solid #eee;padding-bottom:16px;';
|
||||
html += '<div style="' + style + '"><div style="font-size:13px;font-weight:600;color:#555;margin-bottom:8px;">第' + (i+1) + '段' + (isFocused ? ' <span style="color:#4a6cf7;">← 当前</span>' : '') + '</div>';
|
||||
html += seg.videoUrl ? '<video src="' + seg.videoUrl + '" controls style="max-width:100%;max-height:400px;border-radius:8px;"></video>' : '<div style="color:#888;font-size:13px;">无视频</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
document.getElementById('reviewSegments').innerHTML = html;
|
||||
document.getElementById('reviewSegments').style.display = 'block';
|
||||
document.getElementById('reviewBody').style.display = 'none';
|
||||
document.getElementById('reviewLoading').style.display = 'none';
|
||||
document.getElementById('reviewModalTitle').textContent = '视频预览';
|
||||
document.getElementById('reviewStatus').textContent = '';
|
||||
document.getElementById('reviewModal').classList.add('show');
|
||||
}).catch(err => alert('加载预览失败: ' + err.message));
|
||||
}
|
||||
|
||||
function previewSegment(epId, segIdx) { previewEpisode(epId, segIdx); }
|
||||
|
||||
async function continueSegment() {
|
||||
if (!currentTaskId) return;
|
||||
const btn = document.getElementById('continueBtn'); btn.disabled = true; btn.textContent = '处理中...';
|
||||
try {
|
||||
const resp = await fetch('/drama/segment/continue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: currentTaskId }) });
|
||||
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
|
||||
hideReviewPanel(); document.getElementById('reviewStatus').textContent = '已确认,继续生成...';
|
||||
} catch (err) { alert('操作失败: ' + err.message); }
|
||||
finally { btn.disabled = false; btn.textContent = '继续下一段'; }
|
||||
}
|
||||
|
||||
async function feedbackSegment() {
|
||||
if (!currentTaskId) return;
|
||||
const feedback = document.getElementById('feedbackInput').value.trim();
|
||||
if (!feedback) { alert('请输入反馈意见'); return; }
|
||||
const btn = document.getElementById('feedbackBtn'); btn.disabled = true; btn.textContent = '处理中...';
|
||||
try {
|
||||
const resp = await fetch('/drama/segment/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: currentTaskId, feedback }) });
|
||||
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
|
||||
hideReviewPanel(); document.getElementById('reviewStatus').textContent = '已提交反馈,重新生成...';
|
||||
} catch (err) { alert('操作失败: ' + err.message); }
|
||||
finally { btn.disabled = false; btn.textContent = '重新生成'; }
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
function escHtml(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function escAttr(s) { if (!s) return ''; return s.replace(/'/g, ''').replace(/"/g, '"'); }
|
||||
|
||||
loadList();
|
||||
|
||||
// ==================== 模型配置弹窗 ====================
|
||||
function showConfigModal() { document.getElementById('configModal').classList.add('show'); loadConfigForm(); }
|
||||
function hideConfigModal() { document.getElementById('configModal').classList.remove('show'); }
|
||||
async function loadConfigForm() {
|
||||
try {
|
||||
const resp = await fetch('/config/model'); const data = await resp.json();
|
||||
if (data.code !== 0) throw new Error(data.message);
|
||||
const cfg = data.data || {};
|
||||
document.getElementById('cfg_chatApiKey').value = cfg.chatApiKey || '';
|
||||
document.getElementById('cfg_chatBaseURL').value = cfg.chatBaseUrl || '';
|
||||
document.getElementById('cfg_chatModelName').value = cfg.chatModelName || '';
|
||||
document.getElementById('cfg_videoBaseURL').value = cfg.videoBaseUrl || '';
|
||||
document.getElementById('cfg_videoQueryURL').value = cfg.videoQueryUrl || '';
|
||||
document.getElementById('cfg_videoApiKey').value = cfg.videoApiKey || '';
|
||||
document.getElementById('cfg_videoModelName').value = cfg.videoModelName || '';
|
||||
if (cfg.maxTokens) document.getElementById('cfg_maxTokens').value = cfg.maxTokens;
|
||||
if (cfg.temperature) document.getElementById('cfg_temperature').value = cfg.temperature;
|
||||
if (cfg.maxSingleDuration) document.getElementById('cfg_maxSingleDuration').value = cfg.maxSingleDuration;
|
||||
if (cfg.minSingleDuration) document.getElementById('cfg_minSingleDuration').value = cfg.minSingleDuration;
|
||||
} catch (err) { console.error('加载配置失败:', err); }
|
||||
}
|
||||
async function saveConfig() {
|
||||
const btn = document.getElementById('cfg_saveBtn'); const loading = document.getElementById('cfg_loading'); const success = document.getElementById('cfg_successMsg');
|
||||
btn.disabled = true; loading.classList.add('show'); success.classList.remove('show');
|
||||
try {
|
||||
const body = {
|
||||
chatApiKey: document.getElementById("cfg_chatApiKey").value.trim(),
|
||||
videoApiKey: document.getElementById("cfg_videoApiKey").value.trim(),
|
||||
chatBaseUrl: document.getElementById("cfg_chatBaseURL").value.trim(),
|
||||
chatModelName: document.getElementById("cfg_chatModelName").value.trim(),
|
||||
videoBaseUrl: document.getElementById("cfg_videoBaseURL").value.trim(),
|
||||
videoQueryUrl: document.getElementById("cfg_videoQueryURL").value.trim(),
|
||||
videoModelName: document.getElementById("cfg_videoModelName").value.trim(),
|
||||
maxTokens: parseInt(document.getElementById("cfg_maxTokens").value) || 0,
|
||||
temperature: parseFloat(document.getElementById("cfg_temperature").value) || 0,
|
||||
maxSingleDuration: parseInt(document.getElementById("cfg_maxSingleDuration").value) || 30,
|
||||
minSingleDuration: parseInt(document.getElementById("cfg_minSingleDuration").value) || 2,
|
||||
};
|
||||
const resp = await fetch('/config/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
|
||||
success.classList.add('show'); setTimeout(() => success.classList.remove('show'), 3000);
|
||||
} catch (err) { alert('保存失败: ' + err.message); }
|
||||
finally { btn.disabled = false; loading.classList.remove('show'); }
|
||||
}
|
||||
|
||||
// ==================== 提示词配置弹窗 ====================
|
||||
const PROMPT_NAME = 'system_prompt';
|
||||
function showPromptModal() { document.getElementById('promptModal').classList.add('show'); loadPromptForm(); }
|
||||
function hidePromptModal() { document.getElementById('promptModal').classList.remove('show'); }
|
||||
async function loadPromptForm() {
|
||||
const ta = document.getElementById('promptContent'); ta.value = '加载中...';
|
||||
try {
|
||||
const resp = await fetch('/prompt/get?name=' + PROMPT_NAME); const data = await resp.json();
|
||||
if (data.code !== 0) throw new Error(data.message);
|
||||
ta.value = (data.data && data.data.content) || '';
|
||||
} catch (err) { ta.value = '加载失败: ' + err.message; }
|
||||
}
|
||||
async function savePrompt() {
|
||||
const btn = document.getElementById('prompt_saveBtn'); const loading = document.getElementById('prompt_loading'); const success = document.getElementById('prompt_successMsg');
|
||||
btn.disabled = true; loading.classList.add('show'); success.classList.remove('show');
|
||||
try {
|
||||
const body = { name: PROMPT_NAME, content: document.getElementById('promptContent').value };
|
||||
const resp = await fetch('/prompt/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
|
||||
success.classList.add('show'); setTimeout(() => success.classList.remove('show'), 3000);
|
||||
} catch (err) { alert('保存失败: ' + err.message); }
|
||||
finally { btn.disabled = false; loading.classList.remove('show'); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ==================== 提示词配置弹窗 ==================== -->
|
||||
<div class="modal-overlay" id="promptModal">
|
||||
<div class="modal modal-wide" style="max-width:720px;">
|
||||
<h3>提示词配置</h3>
|
||||
<div class="help-text" style="margin-bottom:12px;">修改后保存即时生效,下次生成时使用新提示词</div>
|
||||
<div class="form-group">
|
||||
<textarea id="promptContent" rows="25" placeholder="加载中..." style="width:100%;min-height:300px;padding:12px;border:1px solid #d9d9d9;border-radius:8px;font-size:13px;font-family:monospace;line-height:1.6;outline:none;resize:vertical;"></textarea>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:10px;justify-content:center;align-items:center;">
|
||||
<div class="loading" id="prompt_loading" style="display:none;align-items:center;gap:6px;color:#888;font-size:13px;"><div class="spinner"></div><span>保存中...</span></div>
|
||||
<button class="btn btn-primary" id="prompt_saveBtn" onclick="savePrompt()">保存提示词</button>
|
||||
<button class="btn btn-outline" onclick="hidePromptModal()">取消</button>
|
||||
</div>
|
||||
<div class="success-msg" id="prompt_successMsg">提示词已保存</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型配置弹窗 -->
|
||||
<div class="modal-overlay" id="configModal">
|
||||
<div class="modal modal-wide" style="max-width:560px;">
|
||||
<h3>模型配置</h3>
|
||||
<div class="form-group"><label>对话 API Key</label><input type="password" id="cfg_chatApiKey" placeholder="sk-..."><div class="help-text">对话模型 API 密钥</div></div>
|
||||
<h4 style="font-size:13px;margin:14px 0 8px;color:#1a1a2e;border-left:3px solid #4a6cf7;padding-left:8px;">对话模型(Agent 文字推理)</h4>
|
||||
<div class="form-group"><label>API 地址</label><input type="text" id="cfg_chatBaseURL" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"></div>
|
||||
<div class="form-group"><label>对话模型</label><input type="text" id="cfg_chatModelName" placeholder="qwen3.7-plus, gpt-4o, deepseek-chat 等"></div>
|
||||
<h4 style="font-size:13px;margin:14px 0 8px;color:#1a1a2e;border-left:3px solid #4a6cf7;padding-left:8px;">视频生成模型</h4>
|
||||
<div class="form-group"><label>视频 API Key</label><input type="password" id="cfg_videoApiKey" placeholder="sk-..."><div class="help-text">视频生成 API 密钥,可与对话模型不同</div></div>
|
||||
<div class="form-group"><label>视频 API 地址</label><input type="text" id="cfg_videoBaseURL" placeholder="https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"></div>
|
||||
<div class="form-group"><label>视频模型</label><input type="text" id="cfg_videoModelName" placeholder="wan2.7-t2v"></div>
|
||||
<div class="form-group"><label>视频查询地址</label><input type="text" id="cfg_videoQueryURL" placeholder="https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}"><div class="help-text">用 {task_id} 占位,轮询时自动替换</div></div>
|
||||
<div class="row">
|
||||
<div class="form-group"><label>Max Tokens</label><input type="number" id="cfg_maxTokens" value="4096" min="256" max="32768"></div>
|
||||
<div class="form-group"><label>Temperature</label><input type="number" id="cfg_temperature" value="0.8" min="0" max="2" step="0.1"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group"><label>单段最小时长(秒)</label><input type="number" id="cfg_minSingleDuration" value="2" min="1" max="60"><div class="help-text">不足时从前面的段借</div></div>
|
||||
<div class="form-group"><label>单段最大时长(秒)</label><input type="number" id="cfg_maxSingleDuration" value="30" min="5" max="300"><div class="help-text">超出会自动分段生成</div></div>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:10px;justify-content:center;align-items:center;">
|
||||
<div class="loading" id="cfg_loading" style="display:none;align-items:center;gap:6px;color:#888;font-size:13px;"><div class="spinner"></div><span>保存中...</span></div>
|
||||
<button class="btn btn-primary" id="cfg_saveBtn" onclick="saveConfig()">保存配置</button>
|
||||
<button class="btn btn-outline" onclick="hideConfigModal()">取消</button>
|
||||
</div>
|
||||
<div class="success-msg" id="cfg_successMsg">配置已保存</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user