Skip to content
Open
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
1 change: 1 addition & 0 deletions src-tauri/examples/media_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ async fn smoke_canvas(
"A simple red circle centered on a clean white background, minimal composition.",
&output,
false, // Standard smoke test keeps the platform's preferred renderer.
&diffusion_runtime::ImageRenderOptions::default(),
&|stage| println!("[Canvas] {stage}"),
&|step, total| println!("[Canvas] sampling step {step}/{total}"),
)
Expand Down
81 changes: 80 additions & 1 deletion src-tauri/src/diffusion_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,42 @@ struct VideoRenderProfile {
vae_on_cpu: bool,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageRenderOptions {
pub negative_prompt: Option<String>,
pub seed: Option<i64>,
pub steps: Option<u32>,
}

impl ImageRenderOptions {
pub fn validate(&self) -> Result<(), AppError> {
if self
.negative_prompt
.as_deref()
.is_some_and(|text| text.chars().count() > MAX_PROMPT_CHARS)
{
return Err(AppError::ArtifactGenerationFailed(format!(
"negative prompt is too long; maximum is {MAX_PROMPT_CHARS} characters"
)));
}
if self.steps.is_some_and(|steps| !(1..=50).contains(&steps)) {
return Err(AppError::ArtifactGenerationFailed(
"image sampling steps must be between 1 and 50".to_string(),
));
}
if self
.seed
.is_some_and(|seed| !(0..=2_147_483_647).contains(&seed))
{
return Err(AppError::ArtifactGenerationFailed(
"image seed must be between 0 and 2147483647".to_string(),
));
}
Ok(())
}
}

// Keep the render's independent progress callback alongside its existing inputs.
#[allow(clippy::too_many_arguments)]
pub async fn generate_image(
Expand All @@ -203,9 +239,11 @@ pub async fn generate_image(
prompt: &str,
output_path: &Path,
use_cpu: bool,
options: &ImageRenderOptions,
on_stage: &(dyn Fn(&'static str) + Send + Sync),
on_step: &(dyn Fn(u32, u32) + Send + Sync),
) -> Result<(), AppError> {
options.validate()?;
let prompt = prompt.trim();
if prompt.is_empty() {
return Err(AppError::ArtifactGenerationFailed(
Expand All @@ -230,7 +268,10 @@ pub async fn generate_image(
let runtime = ensure_runtime(root, client, hardware, use_cpu).await?;
on_stage("rendering");
let cli_path = root.resolve_relative(&runtime.cli_path)?;
let profile = render_profile(hardware, &runtime.backend);
let mut profile = render_profile(hardware, &runtime.backend);
if let Some(steps) = options.steps {
profile.steps = steps;
}

if output_path.exists() {
fs::remove_file(output_path)?;
Expand Down Expand Up @@ -260,6 +301,14 @@ pub async fn generate_image(
.stderr(Stdio::piped())
.kill_on_drop(true);

if let Some(negative) = options.negative_prompt.as_deref().map(str::trim) {
if !negative.is_empty() {
command.arg("--negative-prompt").arg(negative);
}
}
if let Some(seed) = options.seed {
command.arg("--seed").arg(seed.to_string());
}
if profile.clip_on_cpu {
command.arg("--clip-on-cpu");
}
Expand Down Expand Up @@ -1378,6 +1427,36 @@ mod tests {
assert_eq!(automatic[0].0, BackendKind::Cuda);
}

#[test]
fn image_options_validate_requested_seed_steps_and_prompt() {
assert!(ImageRenderOptions::default().validate().is_ok());
assert!(ImageRenderOptions {
seed: Some(42),
steps: Some(20),
negative_prompt: Some("blurry, low resolution".to_string()),
}
.validate()
.is_ok());
assert!(ImageRenderOptions {
seed: Some(-1),
..ImageRenderOptions::default()
}
.validate()
.is_err());
assert!(ImageRenderOptions {
steps: Some(51),
..ImageRenderOptions::default()
}
.validate()
.is_err());
assert!(ImageRenderOptions {
negative_prompt: Some("x".repeat(MAX_PROMPT_CHARS + 1)),
..ImageRenderOptions::default()
}
.validate()
.is_err());
}

#[test]
fn sampling_progress_ignores_unrelated_logs() {
assert_eq!(parse_sampling_step("Sampling step 3/20", 20), Some(3));
Expand Down
16 changes: 14 additions & 2 deletions src-tauri/src/lib_legacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,8 +639,18 @@ async fn create_generation_artifact(
kind: String,
prompt: String,
use_cpu: Option<bool>,
image_options: Option<diffusion_runtime::ImageRenderOptions>,
state: State<'_, AppState>,
) -> Result<Artifact, app_error::AppError> {
if kind != "image" && image_options.is_some() {
return Err(app_error::AppError::internal(
"image rendering options are only available for images",
));
}
let image_options = image_options.unwrap_or_default();
if kind == "image" {
image_options.validate()?;
}
if use_cpu == Some(true) && kind != "image" {
return Err(app_error::AppError::internal(
"CPU-only retry is available for images only",
Expand Down Expand Up @@ -730,7 +740,7 @@ async fn create_generation_artifact(
};
report_stage("preparing");
generate_local_media_artifact(
&state, &kind, &prompt, &path, use_cpu.unwrap_or(false), &report_stage, &report_step
&state, &kind, &prompt, &path, use_cpu.unwrap_or(false), &image_options, &report_stage, &report_step
).await
};
tokio::pin!(render);
Expand Down Expand Up @@ -762,7 +772,7 @@ async fn create_generation_artifact(
}
} else {
generate_local_media_artifact(
&state, &kind, &prompt, &path, use_cpu.unwrap_or(false), &report_stage, &report_step
&state, &kind, &prompt, &path, use_cpu.unwrap_or(false), &image_options, &report_stage, &report_step
).await
};
// A cancellation arriving just as inference finishes must not publish a ready image.
Expand Down Expand Up @@ -811,6 +821,7 @@ async fn generate_local_media_artifact(
prompt: &str,
path: &std::path::Path,
use_cpu: bool,
image_options: &diffusion_runtime::ImageRenderOptions,
on_stage: &(dyn Fn(&'static str) + Send + Sync),
on_step: &(dyn Fn(u32, u32) + Send + Sync),
) -> Result<(), app_error::AppError> {
Expand All @@ -837,6 +848,7 @@ async fn generate_local_media_artifact(
prompt,
path,
use_cpu,
image_options,
on_stage,
on_step,
)
Expand Down
24 changes: 22 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { api } from "./api";
import type {
Artifact,
ArtifactKind,
ImageGenerationOptions,
ImageRenderProgress,
Conversation,
AppPreferences,
Expand Down Expand Up @@ -103,6 +104,11 @@ export function App() {
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [imageOptions, setImageOptions] = useState<ImageGenerationOptions>({
negativePrompt: "",
seed: null,
steps: null,
});
const [imageProgressByArtifact, setImageProgressByArtifact] = useState<Record<string, ImageRenderProgress>>({});
const [prompt, setPrompt] = useState("");
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
Expand Down Expand Up @@ -580,6 +586,8 @@ export function App() {
assistant.id,
generationKind,
generationPrompt,
false,
generationKind === "image" ? imageOptions : undefined,
);
setArtifacts((items) => upsertArtifactInList(items, artifact));
if (preferences?.openArtifactsAfterGeneration && artifact.status === "ready") {
Expand Down Expand Up @@ -618,6 +626,7 @@ export function App() {
attachments,
conversations,
editingMessageId,
imageOptions,
messages,
pendingModelId,
preferences?.autoGenerateTitles,
Expand Down Expand Up @@ -1043,7 +1052,14 @@ export function App() {
? imageRendererPrompt(source.content, precedingUser.content)
: source.content;
void api
.createGenerationArtifact(activeId, artifact.messageId, generationKind, generationPrompt, useCpu)
.createGenerationArtifact(
activeId,
artifact.messageId,
generationKind,
generationPrompt,
useCpu,
generationKind === "image" ? imageOptions : undefined,
)
.then((next) => {
setArtifacts((items) => upsertArtifactInList(items, next));
if (preferences?.openArtifactsAfterGeneration && next.status === "ready") {
Expand All @@ -1055,7 +1071,7 @@ export function App() {
}
void createArtifact(artifact.messageId, artifact.kind, source.content, artifact.name);
},
[activeId, createArtifact, messages, preferences?.openArtifactsAfterGeneration, showError],
[activeId, createArtifact, imageOptions, messages, preferences?.openArtifactsAfterGeneration, showError],
);

const cancelImageArtifact = useCallback(
Expand Down Expand Up @@ -1288,6 +1304,8 @@ export function App() {
messages={messages}
prompt={prompt}
setPrompt={setPrompt}
imageOptions={imageOptions}
setImageOptions={setImageOptions}
attachments={attachments}
preferences={preferences}
activity={activity}
Expand Down Expand Up @@ -1344,6 +1362,8 @@ export function App() {
messages={messages}
prompt={prompt}
setPrompt={setPrompt}
imageOptions={imageOptions}
setImageOptions={setImageOptions}
attachments={attachments}
preferences={preferences}
activity={activity}
Expand Down
4 changes: 3 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
Artifact,
ImageGenerationOptions,
LlamaRuntimeStatus,
Message,
RuntimeInventory,
Expand Down Expand Up @@ -368,11 +369,12 @@ export const api = {
kind: "image" | "video" | "voice",
prompt: string,
useCpu = false,
imageOptions?: ImageGenerationOptions,
): Promise<Artifact> => {
if (kind === "voice" && isTauri && (await messageUsesSoundscape(conversationId, messageId))) {
return createSoundscapeArtifact(conversationId, messageId, prompt);
}
return legacyApi.createGenerationArtifact(conversationId, messageId, kind, prompt, useCpu);
return legacyApi.createGenerationArtifact(conversationId, messageId, kind, prompt, useCpu, imageOptions);
},
googleWorkspaceStatus: () => connectedInvoke<GoogleWorkspaceStatus>("google_workspace_status"),
connectGoogleWorkspace: () => connectedInvoke<GoogleWorkspaceStatus>("connect_google_workspace"),
Expand Down
11 changes: 10 additions & 1 deletion src/api_legacy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
Artifact,
ImageGenerationOptions,
ArtifactKind,
Conversation,
AppPreferences,
Expand Down Expand Up @@ -356,7 +357,15 @@ export const api = {
kind: "image" | "video" | "voice",
prompt: string,
useCpu = false,
) => call<Artifact>("create_generation_artifact", { conversationId, messageId, kind, prompt, useCpu }),
imageOptions?: ImageGenerationOptions,
) => call<Artifact>("create_generation_artifact", {
conversationId,
messageId,
kind,
prompt,
useCpu,
imageOptions: kind === "image" ? imageOptions : undefined,
}),
listArtifacts: (conversationId: string) => call<Artifact[]>("list_artifacts", { conversationId }),
listLibraryEntries: () => call<LibraryEntry[]>("list_library_entries"),
openArtifact: (artifactId: string) => call<void>("open_artifact", { artifactId }),
Expand Down
5 changes: 5 additions & 0 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
Artifact,
ArtifactKind,
ImageRenderProgress,
ImageGenerationOptions,
Conversation,
Message,
ModelRecord,
Expand Down Expand Up @@ -52,6 +53,8 @@ export function ChatView(props: {
messages: Message[];
prompt: string;
setPrompt: (value: string) => void;
imageOptions: ImageGenerationOptions;
setImageOptions: (options: ImageGenerationOptions) => void;
attachments: AttachmentDraft[];
preferences: AppPreferences | null;
activity: RealtimeActivity;
Expand Down Expand Up @@ -113,6 +116,8 @@ export function ChatView(props: {
<Composer
prompt={props.prompt}
setPrompt={props.setPrompt}
imageOptions={props.imageOptions}
setImageOptions={props.setImageOptions}
attachments={props.attachments}
enterToSend={props.enterToSend}
streaming={props.streaming}
Expand Down
Loading
Loading