Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
58 changes: 54 additions & 4 deletions core/http/routes/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{}{
Expand Down
300 changes: 300 additions & 0 deletions core/http/static/video.js
Original file line number Diff line number Diff line change
@@ -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 = '<p class="text-xs text-red-500 p-2">Error processing image files: ' + error.message + '</p>';
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 = '<p class="text-xs text-red-500 p-2">Error: ' + json.error.message + '</p>';
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 = '<strong>Prompt:</strong> ' + 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 = '<strong>Negative Prompt:</strong> ' + 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 = `
<span><strong>Size:</strong> ${width}x${height}</span>
${fps ? `<span><strong>FPS:</strong> ${fps}</span>` : ''}
${numFrames !== undefined ? `<span><strong>Frames:</strong> ${numFrames}</span>` : ''}
${seconds !== undefined ? `<span><strong>Duration:</strong> ${seconds}s</span>` : ''}
${step !== undefined ? `<span><strong>Steps:</strong> ${step}</span>` : ''}
${seed !== undefined ? `<span><strong>Seed:</strong> ${seed}</span>` : ''}
${cfgScale !== undefined ? `<span><strong>CFG Scale:</strong> ${cfgScale}</span>` : ''}
`;
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 = '<i class="fas fa-copy mr-1"></i>Copy Prompt';
copyBtn.onclick = () => {
navigator.clipboard.writeText(prompt).then(() => {
copyBtn.innerHTML = '<i class="fas fa-check mr-1"></i>Copied!';
setTimeout(() => {
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>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 = '<p class="text-xs text-[var(--color-text-secondary)] p-2">No videos were generated.</p>';
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
}

} catch (error) {
console.error("Error generating video:", error);
resultDiv.innerHTML = '<p class="text-xs text-red-500 p-2">Error: ' + error.message + '</p>';
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");
}
});
Loading
Loading