diff --git a/core/cli/run.go b/core/cli/run.go index 912bd5522b7c..517052b9c52a 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -56,7 +56,7 @@ type RunCMD struct { UseSubtleKeyComparison bool `env:"LOCALAI_SUBTLE_KEY_COMPARISON" default:"false" help:"If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resiliancy against timing attacks." group:"hardening"` DisableApiKeyRequirementForHttpGet bool `env:"LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET" default:"false" help:"If true, a valid API key is not required to issue GET requests to portions of the web ui. This should only be enabled in secure testing environments" group:"hardening"` DisableMetricsEndpoint bool `env:"LOCALAI_DISABLE_METRICS_ENDPOINT,DISABLE_METRICS_ENDPOINT" default:"false" help:"Disable the /metrics endpoint" group:"api"` - HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"` + HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"` Peer2Peer bool `env:"LOCALAI_P2P,P2P" name:"p2p" default:"false" help:"Enable P2P mode" group:"p2p"` Peer2PeerDHTInterval int `env:"LOCALAI_P2P_DHT_INTERVAL,P2P_DHT_INTERVAL" default:"360" name:"p2p-dht-interval" help:"Interval for DHT refresh (used during token generation)" group:"p2p"` Peer2PeerOTPInterval int `env:"LOCALAI_P2P_OTP_INTERVAL,P2P_OTP_INTERVAL" default:"9000" name:"p2p-otp-interval" help:"Interval for OTP refresh (used during token generation)" group:"p2p"` diff --git a/core/http/routes/ui.go b/core/http/routes/ui.go index da6f5d1ee7f5..bfe93224a26d 100644 --- a/core/http/routes/ui.go +++ b/core/http/routes/ui.go @@ -219,7 +219,7 @@ func RegisterUIRoutes(app *echo.Echo, return c.Render(200, "views/chat", summary) }) - app.GET("/text2image/:model", func(c echo.Context) error { + app.GET("/image/:model", func(c echo.Context) error { modelConfigs := cl.GetAllModelsConfigs() modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) @@ -233,10 +233,10 @@ func RegisterUIRoutes(app *echo.Echo, } // Render index - return c.Render(200, "views/text2image", summary) + return c.Render(200, "views/image", summary) }) - app.GET("/text2image", func(c echo.Context) error { + app.GET("/image", func(c echo.Context) error { modelConfigs := cl.GetAllModelsConfigs() modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) @@ -266,7 +266,7 @@ func RegisterUIRoutes(app *echo.Echo, } // Render index - return c.Render(200, "views/text2image", summary) + return c.Render(200, "views/image", summary) }) app.GET("/tts/:model", func(c echo.Context) error { @@ -318,6 +318,56 @@ func RegisterUIRoutes(app *echo.Echo, return c.Render(200, "views/tts", summary) }) + app.GET("/video/:model", func(c echo.Context) error { + modelConfigs := cl.GetAllModelsConfigs() + modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) + + summary := map[string]interface{}{ + "Title": "LocalAI - Generate videos with " + c.Param("model"), + "BaseURL": middleware.BaseURL(c), + "ModelsConfig": modelConfigs, + "ModelsWithoutConfig": modelsWithoutConfig, + "Model": c.Param("model"), + "Version": internal.PrintableVersion(), + } + + // Render index + return c.Render(200, "views/video", summary) + }) + + app.GET("/video", func(c echo.Context) error { + modelConfigs := cl.GetAllModelsConfigs() + modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) + + if len(modelConfigs)+len(modelsWithoutConfig) == 0 { + // If no model is available redirect to the index which suggests how to install models + return c.Redirect(302, middleware.BaseURL(c)) + } + + modelThatCanBeUsed := "" + title := "LocalAI - Generate videos" + + for _, b := range modelConfigs { + if b.HasUsecases(config.FLAG_VIDEO) { + modelThatCanBeUsed = b.Name + title = "LocalAI - Generate videos with " + modelThatCanBeUsed + break + } + } + + summary := map[string]interface{}{ + "Title": title, + "BaseURL": middleware.BaseURL(c), + "ModelsConfig": modelConfigs, + "ModelsWithoutConfig": modelsWithoutConfig, + "Model": modelThatCanBeUsed, + "Version": internal.PrintableVersion(), + } + + // Render index + return c.Render(200, "views/video", summary) + }) + // Traces UI app.GET("/traces", func(c echo.Context) error { summary := map[string]interface{}{ diff --git a/core/http/static/video.js b/core/http/static/video.js new file mode 100644 index 000000000000..bcc2f782a468 --- /dev/null +++ b/core/http/static/video.js @@ -0,0 +1,300 @@ +// Helper function to convert file to base64 +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + // Remove data:image/...;base64, prefix if present + const base64 = reader.result.split(',')[1] || reader.result; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function genVideo(event) { + event.preventDefault(); + promptVideo(); +} + +async function promptVideo() { + const loader = document.getElementById("loader"); + const input = document.getElementById("input"); + const generateBtn = document.getElementById("generate-btn"); + const resultDiv = document.getElementById("result"); + const resultPlaceholder = document.getElementById("result-placeholder"); + + // Show loader and disable form + loader.classList.remove("hidden"); + if (resultPlaceholder) { + resultPlaceholder.style.display = "none"; + } + input.disabled = true; + generateBtn.disabled = true; + + // Store the prompt for later restoration + const prompt = input.value.trim(); + if (!prompt) { + alert("Please enter a prompt"); + loader.classList.add("hidden"); + if (resultPlaceholder) { + resultPlaceholder.style.display = "flex"; + } + input.disabled = false; + generateBtn.disabled = false; + return; + } + + // Collect all form values + const model = document.getElementById("video-model").value; + const size = document.getElementById("video-size").value; + const negativePrompt = document.getElementById("negative-prompt").value.trim(); + + // Parse size into width and height + const sizeParts = size.split("x"); + let width = 512; + let height = 512; + if (sizeParts.length === 2) { + width = parseInt(sizeParts[0]) || 512; + height = parseInt(sizeParts[1]) || 512; + } + + // Video-specific parameters + const secondsInput = document.getElementById("video-seconds").value.trim(); + const seconds = secondsInput ? secondsInput : undefined; + const fpsInput = document.getElementById("video-fps").value.trim(); + const fps = fpsInput ? parseInt(fpsInput) : 16; + const framesInput = document.getElementById("video-frames").value.trim(); + const numFrames = framesInput ? parseInt(framesInput) : undefined; + + // Advanced parameters + const stepInput = document.getElementById("video-steps").value.trim(); + const step = stepInput ? parseInt(stepInput) : undefined; + const seedInput = document.getElementById("video-seed").value.trim(); + const seed = seedInput ? parseInt(seedInput) : undefined; + const cfgScaleInput = document.getElementById("video-cfg-scale").value.trim(); + const cfgScale = cfgScaleInput ? parseFloat(cfgScaleInput) : undefined; + + // Prepare request body + const requestBody = { + model: model, + prompt: prompt, + width: width, + height: height, + fps: fps, + }; + + if (negativePrompt) { + requestBody.negative_prompt = negativePrompt; + } + + if (seconds !== undefined) { + requestBody.seconds = seconds; + } + + if (numFrames !== undefined) { + requestBody.num_frames = numFrames; + } + + if (step !== undefined) { + requestBody.step = step; + } + + if (seed !== undefined) { + requestBody.seed = seed; + } + + if (cfgScale !== undefined) { + requestBody.cfg_scale = cfgScale; + } + + // Handle file inputs + try { + // Start image (for img2video) + const startImageInput = document.getElementById("start-image"); + if (startImageInput.files.length > 0) { + const base64 = await fileToBase64(startImageInput.files[0]); + requestBody.start_image = base64; + } + + // End image + const endImageInput = document.getElementById("end-image"); + if (endImageInput.files.length > 0) { + const base64 = await fileToBase64(endImageInput.files[0]); + requestBody.end_image = base64; + } + } catch (error) { + console.error("Error processing image files:", error); + resultDiv.innerHTML = '

Error processing image files: ' + error.message + '

'; + loader.classList.add("hidden"); + if (resultPlaceholder) { + resultPlaceholder.style.display = "none"; + } + input.disabled = false; + generateBtn.disabled = false; + return; + } + + // Make API request + try { + const response = await fetch("v1/videos/generations", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const json = await response.json(); + + if (json.error) { + // Display error + resultDiv.innerHTML = '

Error: ' + json.error.message + '

'; + loader.classList.add("hidden"); + if (resultPlaceholder) { + resultPlaceholder.style.display = "none"; + } + input.disabled = false; + generateBtn.disabled = false; + return; + } + + // Clear result div and hide placeholder + resultDiv.innerHTML = ''; + if (resultPlaceholder) { + resultPlaceholder.style.display = "none"; + } + + // Display generated video + if (json.data && json.data.length > 0) { + json.data.forEach((item, index) => { + const videoContainer = document.createElement("div"); + videoContainer.className = "flex flex-col"; + + // Create video element + const video = document.createElement("video"); + video.controls = true; + video.className = "w-full h-auto rounded-lg"; + video.preload = "metadata"; + + if (item.url) { + video.src = item.url; + } else if (item.b64_json) { + video.src = "data:video/mp4;base64," + item.b64_json; + } else { + return; // Skip invalid items + } + + videoContainer.appendChild(video); + + // Create caption container + const captionDiv = document.createElement("div"); + captionDiv.className = "mt-2 p-2 bg-[var(--color-bg-secondary)] rounded-lg text-xs"; + + // Prompt caption + const promptCaption = document.createElement("p"); + promptCaption.className = "text-[var(--color-text-primary)] mb-1.5 break-words"; + promptCaption.innerHTML = 'Prompt: ' + escapeHtml(prompt); + captionDiv.appendChild(promptCaption); + + // Negative prompt if provided + if (negativePrompt) { + const negativeCaption = document.createElement("p"); + negativeCaption.className = "text-[var(--color-text-secondary)] mb-1.5 break-words"; + negativeCaption.innerHTML = 'Negative Prompt: ' + escapeHtml(negativePrompt); + captionDiv.appendChild(negativeCaption); + } + + // Generation details + const detailsDiv = document.createElement("div"); + detailsDiv.className = "flex flex-wrap gap-3 text-[10px] text-[var(--color-text-secondary)] mt-1.5"; + detailsDiv.innerHTML = ` + Size: ${width}x${height} + ${fps ? `FPS: ${fps}` : ''} + ${numFrames !== undefined ? `Frames: ${numFrames}` : ''} + ${seconds !== undefined ? `Duration: ${seconds}s` : ''} + ${step !== undefined ? `Steps: ${step}` : ''} + ${seed !== undefined ? `Seed: ${seed}` : ''} + ${cfgScale !== undefined ? `CFG Scale: ${cfgScale}` : ''} + `; + captionDiv.appendChild(detailsDiv); + + // Copy prompt button + const copyBtn = document.createElement("button"); + copyBtn.className = "mt-1.5 px-2 py-0.5 text-[10px] bg-[var(--color-primary)] text-white rounded hover:opacity-80"; + copyBtn.innerHTML = 'Copy Prompt'; + copyBtn.onclick = () => { + navigator.clipboard.writeText(prompt).then(() => { + copyBtn.innerHTML = 'Copied!'; + setTimeout(() => { + copyBtn.innerHTML = 'Copy Prompt'; + }, 2000); + }); + }; + captionDiv.appendChild(copyBtn); + + videoContainer.appendChild(captionDiv); + resultDiv.appendChild(videoContainer); + }); + // Hide placeholder when videos are displayed + if (resultPlaceholder) { + resultPlaceholder.style.display = "none"; + } + } else { + resultDiv.innerHTML = '

No videos were generated.

'; + if (resultPlaceholder) { + resultPlaceholder.style.display = "none"; + } + } + + } catch (error) { + console.error("Error generating video:", error); + resultDiv.innerHTML = '

Error: ' + error.message + '

'; + if (resultPlaceholder) { + resultPlaceholder.style.display = "none"; + } + } finally { + // Hide loader and re-enable form + loader.classList.add("hidden"); + input.disabled = false; + generateBtn.disabled = false; + input.focus(); + } +} + +// Helper function to escape HTML +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +// Initialize +document.addEventListener("DOMContentLoaded", function() { + const input = document.getElementById("input"); + const form = document.getElementById("genvideo"); + + if (input) { + input.focus(); + } + + if (form) { + form.addEventListener("submit", genVideo); + } + + // Handle Enter key press in the prompt input (but allow Shift+Enter for new lines) + if (input) { + input.addEventListener("keydown", function(event) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + genVideo(event); + } + }); + } + + // Hide loader initially + const loader = document.getElementById("loader"); + if (loader) { + loader.classList.add("hidden"); + } +}); diff --git a/core/http/views/text2image.html b/core/http/views/image.html similarity index 97% rename from core/http/views/text2image.html rename to core/http/views/image.html index a7bc0ce4f097..e65296db1c3b 100644 --- a/core/http/views/text2image.html +++ b/core/http/views/image.html @@ -28,19 +28,19 @@ {{ $cfg := . }} {{ range .KnownUsecaseStrings }} {{ if eq . "FLAG_IMAGE" }} - + {{ end }} {{ end }} {{ end }} {{ range .ModelsWithoutConfig }} - + {{end}}
-
+
@@ -326,4 +326,4 @@ - \ No newline at end of file + diff --git a/core/http/views/manage.html b/core/http/views/manage.html index 224f762fe16f..f117b0888d0a 100644 --- a/core/http/views/manage.html +++ b/core/http/views/manage.html @@ -315,7 +315,7 @@

{{ end }} {{ if eq . "FLAG_IMAGE" }} - + Image {{ end }} diff --git a/core/http/views/partials/navbar.html b/core/http/views/partials/navbar.html index c6dddfa7f55c..6ffd2770a5fb 100644 --- a/core/http/views/partials/navbar.html +++ b/core/http/views/partials/navbar.html @@ -25,7 +25,7 @@ Chat - + Images @@ -85,7 +85,7 @@ Chat - + Images diff --git a/core/http/views/video.html b/core/http/views/video.html new file mode 100644 index 000000000000..b5a25fd1d845 --- /dev/null +++ b/core/http/views/video.html @@ -0,0 +1,315 @@ + + +{{template "views/partials/head" .}} + + + +
+ + {{template "views/partials/navbar" .}} +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+ + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + + +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+
+
+ + +
+
+ + + +
+

Your generated videos will appear here

+
+ +
+
+
+
+
+ +
+ + + + + diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md index 9c3fada7c4a5..f8349ef7a63e 100644 --- a/docs/content/reference/cli-reference.md +++ b/docs/content/reference/cli-reference.md @@ -97,7 +97,7 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel | `--opaque-errors` | `false` | If true, all error responses are replaced with blank 500 errors. This is intended only for hardening against information leaks and is normally not recommended | `$LOCALAI_OPAQUE_ERRORS` | | `--use-subtle-key-comparison` | `false` | If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resilience against timing attacks | `$LOCALAI_SUBTLE_KEY_COMPARISON` | | `--disable-api-key-requirement-for-http-get` | `false` | If true, a valid API key is not required to issue GET requests to portions of the web UI. This should only be enabled in secure testing environments | `$LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET` | -| `--http-get-exempted-endpoints` | `^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` | +| `--http-get-exempted-endpoints` | `^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` | ## P2P Flags