commit ab2c76f971d2a1bf702baeb7bf5fa4963a4c4a6a Author: 张斌 <259278618@qq.com> Date: Thu Jun 18 16:01:39 2026 +0800 项目初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61069b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea2a982 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/common/http/http.go b/common/http/http.go new file mode 100644 index 0000000..8660bb8 --- /dev/null +++ b/common/http/http.go @@ -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) + } +} diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..9730a71 --- /dev/null +++ b/config.yml @@ -0,0 +1,8 @@ +database: + default: + name: short_drama.db + type: sqlite +server: + address: :3006 + name: video + workerId: 1 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..24d08d7 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..383ff6d --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..31705ab --- /dev/null +++ b/main.go @@ -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 {} +} diff --git a/short_drama.db b/short_drama.db new file mode 100644 index 0000000..6911d02 Binary files /dev/null and b/short_drama.db differ diff --git a/shortdrama/agent/chat_model.go b/shortdrama/agent/chat_model.go new file mode 100644 index 0000000..9b8a8de --- /dev/null +++ b/shortdrama/agent/chat_model.go @@ -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 +} diff --git a/shortdrama/agent/react_agent.go b/shortdrama/agent/react_agent.go new file mode 100644 index 0000000..bf84e05 --- /dev/null +++ b/shortdrama/agent/react_agent.go @@ -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 +} diff --git a/shortdrama/agent/tools.go b/shortdrama/agent/tools.go new file mode 100644 index 0000000..87e2b5a --- /dev/null +++ b/shortdrama/agent/tools.go @@ -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 +} diff --git a/shortdrama/consts/public/table_name.go b/shortdrama/consts/public/table_name.go new file mode 100644 index 0000000..8619562 --- /dev/null +++ b/shortdrama/consts/public/table_name.go @@ -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" +) diff --git a/shortdrama/consts/status.go b/shortdrama/consts/status.go new file mode 100644 index 0000000..63e64ae --- /dev/null +++ b/shortdrama/consts/status.go @@ -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" +) diff --git a/shortdrama/controller/config_controller.go b/shortdrama/controller/config_controller.go new file mode 100644 index 0000000..c122331 --- /dev/null +++ b/shortdrama/controller/config_controller.go @@ -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) +} diff --git a/shortdrama/controller/drama_controller.go b/shortdrama/controller/drama_controller.go new file mode 100644 index 0000000..b2e47cc --- /dev/null +++ b/shortdrama/controller/drama_controller.go @@ -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("旧版接口已弃用,请使用单集生成") +} diff --git a/shortdrama/controller/prompt_controller.go b/shortdrama/controller/prompt_controller.go new file mode 100644 index 0000000..e00e2f3 --- /dev/null +++ b/shortdrama/controller/prompt_controller.go @@ -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) +} diff --git a/shortdrama/dao/character_dao.go b/shortdrama/dao/character_dao.go new file mode 100644 index 0000000..1503804 --- /dev/null +++ b/shortdrama/dao/character_dao.go @@ -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 +} diff --git a/shortdrama/dao/drama_dao.go b/shortdrama/dao/drama_dao.go new file mode 100644 index 0000000..2e36d51 --- /dev/null +++ b/shortdrama/dao/drama_dao.go @@ -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 +} diff --git a/shortdrama/dao/episode_dao.go b/shortdrama/dao/episode_dao.go new file mode 100644 index 0000000..3e8f87f --- /dev/null +++ b/shortdrama/dao/episode_dao.go @@ -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 +} diff --git a/shortdrama/dao/generation_task_dao.go b/shortdrama/dao/generation_task_dao.go new file mode 100644 index 0000000..7d86b48 --- /dev/null +++ b/shortdrama/dao/generation_task_dao.go @@ -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 +} diff --git a/shortdrama/dao/model_config_dao.go b/shortdrama/dao/model_config_dao.go new file mode 100644 index 0000000..12eaa52 --- /dev/null +++ b/shortdrama/dao/model_config_dao.go @@ -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 +} diff --git a/shortdrama/dao/prompt_dao.go b/shortdrama/dao/prompt_dao.go new file mode 100644 index 0000000..de825c8 --- /dev/null +++ b/shortdrama/dao/prompt_dao.go @@ -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 +} diff --git a/shortdrama/model/agent_output.go b/shortdrama/model/agent_output.go new file mode 100644 index 0000000..23b3369 --- /dev/null +++ b/shortdrama/model/agent_output.go @@ -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 +} diff --git a/shortdrama/model/dto/character_dto.go b/shortdrama/model/dto/character_dto.go new file mode 100644 index 0000000..342c4c9 --- /dev/null +++ b/shortdrama/model/dto/character_dto.go @@ -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"` +} diff --git a/shortdrama/model/dto/config_dto.go b/shortdrama/model/dto/config_dto.go new file mode 100644 index 0000000..80c5493 --- /dev/null +++ b/shortdrama/model/dto/config_dto.go @@ -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 +} diff --git a/shortdrama/model/dto/drama_dto.go b/shortdrama/model/dto/drama_dto.go new file mode 100644 index 0000000..3169325 --- /dev/null +++ b/shortdrama/model/dto/drama_dto.go @@ -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"` +} diff --git a/shortdrama/model/dto/episode_dto.go b/shortdrama/model/dto/episode_dto.go new file mode 100644 index 0000000..10dda65 --- /dev/null +++ b/shortdrama/model/dto/episode_dto.go @@ -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"` +} diff --git a/shortdrama/model/dto/prompt_dto.go b/shortdrama/model/dto/prompt_dto.go new file mode 100644 index 0000000..d1c7980 --- /dev/null +++ b/shortdrama/model/dto/prompt_dto.go @@ -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 +} diff --git a/shortdrama/model/dto/segment_dto.go b/shortdrama/model/dto/segment_dto.go new file mode 100644 index 0000000..a7c999a --- /dev/null +++ b/shortdrama/model/dto/segment_dto.go @@ -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"` +} diff --git a/shortdrama/model/entity/character.go b/shortdrama/model/entity/character.go new file mode 100644 index 0000000..72824b5 --- /dev/null +++ b/shortdrama/model/entity/character.go @@ -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"` +} diff --git a/shortdrama/model/entity/drama.go b/shortdrama/model/entity/drama.go new file mode 100644 index 0000000..863cb3f --- /dev/null +++ b/shortdrama/model/entity/drama.go @@ -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"` +} diff --git a/shortdrama/model/entity/episode.go b/shortdrama/model/entity/episode.go new file mode 100644 index 0000000..aa02098 --- /dev/null +++ b/shortdrama/model/entity/episode.go @@ -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"` +} diff --git a/shortdrama/model/entity/generation_task.go b/shortdrama/model/entity/generation_task.go new file mode 100644 index 0000000..79f20e4 --- /dev/null +++ b/shortdrama/model/entity/generation_task.go @@ -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"` +} diff --git a/shortdrama/model/entity/model_config.go b/shortdrama/model/entity/model_config.go new file mode 100644 index 0000000..e646d9f --- /dev/null +++ b/shortdrama/model/entity/model_config.go @@ -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"` +} diff --git a/shortdrama/model/entity/prompt.go b/shortdrama/model/entity/prompt.go new file mode 100644 index 0000000..995fe00 --- /dev/null +++ b/shortdrama/model/entity/prompt.go @@ -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"` +} diff --git a/shortdrama/model/model_config.go b/shortdrama/model/model_config.go new file mode 100644 index 0000000..ca6a80d --- /dev/null +++ b/shortdrama/model/model_config.go @@ -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"` +} diff --git a/shortdrama/model/prompt.go b/shortdrama/model/prompt.go new file mode 100644 index 0000000..490eae8 --- /dev/null +++ b/shortdrama/model/prompt.go @@ -0,0 +1,7 @@ +package model + +type Prompt struct { + Name string `json:"name"` + Content string `json:"content"` + Remark string `json:"remark"` +} diff --git a/shortdrama/model/segment_output.go b/shortdrama/model/segment_output.go new file mode 100644 index 0000000..b4c6b9f --- /dev/null +++ b/shortdrama/model/segment_output.go @@ -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"` +} diff --git a/shortdrama/service/config_service.go b/shortdrama/service/config_service.go new file mode 100644 index 0000000..8a591e2 --- /dev/null +++ b/shortdrama/service/config_service.go @@ -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, + } +} diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go new file mode 100644 index 0000000..3ed1ddf --- /dev/null +++ b/shortdrama/service/drama_service.go @@ -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 +} diff --git a/shortdrama/service/prompt_service.go b/shortdrama/service/prompt_service.go new file mode 100644 index 0000000..3f8468d --- /dev/null +++ b/shortdrama/service/prompt_service.go @@ -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, + } +} diff --git a/shortdrama/view/index.html b/shortdrama/view/index.html new file mode 100644 index 0000000..e93aea8 --- /dev/null +++ b/shortdrama/view/index.html @@ -0,0 +1,849 @@ + + + + + +短剧管理 + + + +
+
+

短剧管理

+
+ + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +